From 33d5f98a76d510080596a4091a409b5818a7594f Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 8 Jul 2026 10:15:27 -0700 Subject: [PATCH 001/225] simplify(S26b): finish the typed trace surface (pool-cap rejection + remaining outcomes) (#4061) Follow-on to merged S26 (#4036): closes the last stringly trace outcomes by finishing the typed trace surface (pool-cap rejection + remaining outcomes). Part of #3789. Gates green; Fable-reviewed, behavior-preserved. Spec at engdocs/simplification/specs/S26b-finish-typed-trace-spec.md. Spike/staged for review, not auto-merge. Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/gc/build_desired_state.go | 6 +- cmd/gc/pool_desired_state.go | 22 +++--- cmd/gc/pool_desired_state_test.go | 71 +++++++++++++++++ cmd/gc/session_lifecycle_parallel.go | 62 +++++++-------- cmd/gc/session_reconciler.go | 73 +++++++++++++++--- cmd/gc/session_reconciler_timer_trace_test.go | 77 +++++++++++++++++++ cmd/gc/session_reconciler_trace_types.go | 12 +++ cmd/gc/session_reconciler_trace_types_test.go | 35 +++++++++ cmd/gc/session_wake.go | 6 +- 9 files changed, 305 insertions(+), 59 deletions(-) create mode 100644 cmd/gc/session_reconciler_timer_trace_test.go create mode 100644 cmd/gc/session_reconciler_trace_types_test.go diff --git a/cmd/gc/build_desired_state.go b/cmd/gc/build_desired_state.go index bd80342d41..39857fd77b 100644 --- a/cmd/gc/build_desired_state.go +++ b/cmd/gc/build_desired_state.go @@ -371,11 +371,11 @@ func evaluatePendingPools( } evalResults[idx] = poolEvalResult{desired: d, err: err} if trace != nil { - outcome := "success" + outcome := TraceOutcomeSuccess if err != nil { - outcome = "failed" + outcome = TraceOutcomeFailed } - trace.RecordOperation(TraceSiteScaleCheckExec, TraceReasonScaleCheck, TraceOutcomeCode(outcome), "", template, "", time.Since(started), traceRecordPayload{ + trace.RecordOperation(TraceSiteScaleCheckExec, TraceReasonScaleCheck, outcome, "", template, "", time.Since(started), traceRecordPayload{ "pool_dir": dir, "command": sp.Check, "desired": d, diff --git a/cmd/gc/pool_desired_state.go b/cmd/gc/pool_desired_state.go index 7b3687d0b9..ae9bcf26cb 100644 --- a/cmd/gc/pool_desired_state.go +++ b/cmd/gc/pool_desired_state.go @@ -459,7 +459,7 @@ func applyNestedCaps(cfg *config.City, requests []SessionRequest, aliasHeldTempl } if site, reason, payload, rejected := usage.rejection(req, limits); rejected { if trace != nil { - trace.RecordDecision(TraceSiteCode(site), TraceReasonCode(reason), TraceOutcomeRejected, template, "", payload) + trace.RecordDecision(site, reason, TraceOutcomeRejected, template, "", payload) } continue } @@ -634,10 +634,10 @@ func (u nestedCapUsage) isDuplicateSessionRequest(req SessionRequest) bool { return req.SessionBeadID != "" && u.seenSessionBead[req.SessionBeadID] } -func (u nestedCapUsage) rejection(req SessionRequest, limits nestedCapLimits) (string, string, traceRecordPayload, bool) { +func (u nestedCapUsage) rejection(req SessionRequest, limits nestedCapLimits) (TraceSiteCode, TraceReasonCode, traceRecordPayload, bool) { template := req.Template if agentMax := limits.agentMax[template]; agentMax >= 0 && u.agentCount[template] >= agentMax { - return "reconciler.pool.agent_cap", "agent_cap", traceRecordPayload{ + return TraceSitePoolAgentCap, TraceReasonAgentCap, traceRecordPayload{ "agent_max": agentMax, "current": u.agentCount[template], "tier": req.Tier, @@ -650,7 +650,7 @@ func (u nestedCapUsage) rejection(req SessionRequest, limits nestedCapLimits) (s rigMax = -1 } if rigMax >= 0 && u.rigCount[rig] >= rigMax { - return "reconciler.pool.rig_cap", "rig_cap", traceRecordPayload{ + return TraceSitePoolRigCap, TraceReasonRigCap, traceRecordPayload{ "rig": rig, "rig_max": rigMax, "current": u.rigCount[rig], @@ -659,7 +659,7 @@ func (u nestedCapUsage) rejection(req SessionRequest, limits nestedCapLimits) (s } } if limits.workspaceMax >= 0 && u.workspaceCount >= limits.workspaceMax { - return "reconciler.pool.workspace_cap", "workspace_cap", traceRecordPayload{ + return TraceSitePoolWorkspaceCap, TraceReasonWorkspaceCap, traceRecordPayload{ "workspace_max": limits.workspaceMax, "current": u.workspaceCount, "tier": req.Tier, @@ -706,7 +706,7 @@ func recordNewDemandCapTrace( blockingWork = append(blockingWork, req.WorkBeadID) } } - trace.RecordDecision(TraceSiteCode(site), TraceReasonCode(reason), TraceOutcomeRejected, template, "", traceRecordPayload{ + trace.RecordDecision(site, reason, TraceOutcomeRejected, template, "", traceRecordPayload{ "scale_check": scaleCount, "accepted_new": newCount, "blocked_new": scaleCount - newCount, @@ -714,7 +714,7 @@ func recordNewDemandCapTrace( "max": capMax, "blocking_sessions": blockingSessions, "blocking_work_beads": blockingWork, - "active_capacity_kind": reason, + "active_capacity_kind": string(reason), }) } @@ -724,9 +724,9 @@ func newDemandBlockingScope( limits nestedCapLimits, usage nestedCapUsage, newCount int, -) (string, string, int, int, []SessionRequest) { +) (TraceSiteCode, TraceReasonCode, int, int, []SessionRequest) { if agentMax := limits.agentMax[template]; agentMax >= 0 && agentMax-usage.agentCount[template] <= newCount { - return string(TraceSitePoolNewDemandCap), string(TraceReasonAgentCap), agentMax, usage.agentCount[template], filterCapBlockers(usage.requests, func(req SessionRequest) bool { + return TraceSitePoolNewDemandCap, TraceReasonAgentCap, agentMax, usage.agentCount[template], filterCapBlockers(usage.requests, func(req SessionRequest) bool { return req.Template == template }) } @@ -737,14 +737,14 @@ func newDemandBlockingScope( rigMax = -1 } if rigMax >= 0 && rigMax-usage.rigCount[rig] <= newCount { - return string(TraceSitePoolNewDemandCap), string(TraceReasonRigCap), rigMax, usage.rigCount[rig], filterCapBlockers(usage.requests, func(req SessionRequest) bool { + return TraceSitePoolNewDemandCap, TraceReasonRigCap, rigMax, usage.rigCount[rig], filterCapBlockers(usage.requests, func(req SessionRequest) bool { return limits.agentRig[req.Template] == rig }) } } } if limits.workspaceMax >= 0 && limits.workspaceMax-usage.workspaceCount <= newCount { - return string(TraceSitePoolNewDemandCap), string(TraceReasonWorkspaceCap), limits.workspaceMax, usage.workspaceCount, usage.requests + return TraceSitePoolNewDemandCap, TraceReasonWorkspaceCap, limits.workspaceMax, usage.workspaceCount, usage.requests } return "", "", 0, 0, nil } diff --git a/cmd/gc/pool_desired_state_test.go b/cmd/gc/pool_desired_state_test.go index 5da37ae354..ec0f3bff6b 100644 --- a/cmd/gc/pool_desired_state_test.go +++ b/cmd/gc/pool_desired_state_test.go @@ -11,6 +11,77 @@ import ( func intPtr(n int) *int { return &n } +// TestNestedCapUsageRejectionTyped verifies that the retyped rejection producer +// returns the typed site/reason constants for each cap kind, and that the +// underlying string values are byte-identical to the pre-S26b literals. +func TestNestedCapUsageRejectionTyped(t *testing.T) { + cases := []struct { + name string + cfg *config.City + wantSite TraceSiteCode + wantReason TraceReasonCode + wantSiteS string + wantRsnS string + }{ + { + name: "agent_cap", + cfg: &config.City{Agents: []config.Agent{poolAgent("claude", "rig", intPtr(1), 0)}}, + wantSite: TraceSitePoolAgentCap, + wantReason: TraceReasonAgentCap, + wantSiteS: "reconciler.pool.agent_cap", + wantRsnS: "agent_cap", + }, + { + name: "rig_cap", + cfg: &config.City{ + Rigs: []config.Rig{{Name: "rig", Path: "/tmp/rig", MaxActiveSessions: intPtr(1)}}, + Agents: []config.Agent{poolAgent("claude", "rig", intPtr(5), 0)}, + }, + wantSite: TraceSitePoolRigCap, + wantReason: TraceReasonRigCap, + wantSiteS: "reconciler.pool.rig_cap", + wantRsnS: "rig_cap", + }, + { + name: "workspace_cap", + cfg: &config.City{ + Workspace: config.Workspace{MaxActiveSessions: intPtr(1)}, + Agents: []config.Agent{poolAgent("claude", "", intPtr(5), 0)}, + }, + wantSite: TraceSitePoolWorkspaceCap, + wantReason: TraceReasonWorkspaceCap, + wantSiteS: "reconciler.pool.workspace_cap", + wantRsnS: "workspace_cap", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + limits := newNestedCapLimits(tc.cfg) + usage := newNestedCapUsage() + template := tc.cfg.Agents[0].QualifiedName() + // Fill to the cap so the next request is rejected. + usage.accept(SessionRequest{Template: template, Tier: "new"}, limits) + + site, reason, _, rejected := usage.rejection(SessionRequest{Template: template, Tier: "new"}, limits) + if !rejected { + t.Fatalf("expected rejection at cap") + } + if site != tc.wantSite { + t.Errorf("site = %q, want %q", site, tc.wantSite) + } + if reason != tc.wantReason { + t.Errorf("reason = %q, want %q", reason, tc.wantReason) + } + if string(site) != tc.wantSiteS { + t.Errorf("string(site) = %q, want legacy literal %q", string(site), tc.wantSiteS) + } + if string(reason) != tc.wantRsnS { + t.Errorf("string(reason) = %q, want legacy literal %q", string(reason), tc.wantRsnS) + } + }) + } +} + func workBead(id, routedTo, assignee, status string, priority int) beads.Bead { p := priority return beads.Bead{ diff --git a/cmd/gc/session_lifecycle_parallel.go b/cmd/gc/session_lifecycle_parallel.go index 27482a6bdb..c9124655ef 100644 --- a/cmd/gc/session_lifecycle_parallel.go +++ b/cmd/gc/session_lifecycle_parallel.go @@ -198,7 +198,7 @@ type preparedStart struct { type startResult struct { prepared preparedStart err error - outcome string + outcome TraceOutcomeCode started time.Time finished time.Time rollbackPending bool @@ -1230,7 +1230,7 @@ func runPreparedStartCandidate( result = startResult{ prepared: item, err: fmt.Errorf("panic during start: %v\n%s", recovered, stack), - outcome: "panic_recovered", + outcome: TraceOutcomePanicRecovered, started: started, finished: time.Now(), } @@ -1296,47 +1296,47 @@ func runPreparedStartCandidate( return startResult{ prepared: item, err: nil, - outcome: "start_error_converged", + outcome: TraceOutcomeStartErrorConverged, started: started, finished: finished, rollbackPending: false, phases: phases, } } - var outcome string + var outcome TraceOutcomeCode switch { case errors.Is(err, runtime.ErrSessionInitializing): - outcome = "session_initializing" + outcome = TraceOutcomeSessionInitializing err = nil case startCtxErr == context.DeadlineExceeded: - outcome = "deadline_exceeded" + outcome = TraceOutcomeDeadlineExceeded if err == nil { err = fmt.Errorf("session %q startup: %w", item.candidate.name(), context.DeadlineExceeded) } case startCtxErr == context.Canceled: - outcome = "canceled" + outcome = TraceOutcomeCanceled if err == nil { err = fmt.Errorf("session %q startup: %w", item.candidate.name(), context.Canceled) } case err == nil: - outcome = "success" + outcome = TraceOutcomeSuccess case errors.Is(err, runtime.ErrSessionExists): obs, runningErr := workerObserveSessionTargetWithRuntimeHintsWithConfig(cityPath, store, sp, cfg, item.candidate.name(), item.cfg.ProcessNames) switch { case runningErr != nil || !runtimeObservationLive(obs): - outcome = "provider_error" + outcome = TraceOutcomeProviderError case rollbackPending && !rateLimitScreen && runningSessionMatchesPendingCreate(item.candidate.session, item.candidate.name(), sp): - outcome = "session_exists_converged" + outcome = TraceOutcomeSessionExistsConverged err = nil rollbackPending = false case rollbackPending: - outcome = "session_exists" + outcome = TraceOutcomeSessionExists default: - outcome = "session_exists" + outcome = TraceOutcomeSessionExists err = nil } default: - outcome = "provider_error" + outcome = TraceOutcomeProviderError } if err == nil { rateLimitScreen = false @@ -1425,7 +1425,7 @@ func enqueuePreparedStartWaveForCity( now := time.Now() results[i] = startResult{ prepared: item, - outcome: "start_enqueued", + outcome: TraceOutcomeStartEnqueued, started: now, finished: now, } @@ -1514,7 +1514,7 @@ func commitAsyncStartResultWithContext( } if refreshed.err != nil && refreshed.rollbackPending && runningSessionMatchesPendingCreate(refreshed.prepared.candidate.session, refreshed.prepared.candidate.name(), sp) { refreshed.err = nil - refreshed.outcome = "session_exists_converged" + refreshed.outcome = TraceOutcomeSessionExistsConverged refreshed.rollbackPending = false } if ctx != nil && ctx.Err() != nil { @@ -1528,7 +1528,7 @@ func commitAsyncStartResultWithContext( logLifecycleOutcome(stderr, "start", wave, name, template, "context_canceled", refreshed.started, time.Now(), ctx.Err(), refreshed.phases) return false } - if sp != nil && refreshed.err == nil && refreshed.outcome != "session_initializing" { + if sp != nil && refreshed.err == nil && refreshed.outcome != TraceOutcomeSessionInitializing { _ = clearReconcilerDrainAckMetadata(sp, refreshed.prepared.candidate.name()) } return commitStartResultTraced(refreshed, sessFront, clk, rec, wave, stdout, stderr, trace) @@ -1924,9 +1924,9 @@ func commitStartResultTraced( tp := result.prepared.candidate.tp // Session still starting up — back off silently without recording failure. // The reconciler will retry on the next patrol tick. - if result.outcome == "session_initializing" { + if result.outcome == TraceOutcomeSessionInitializing { clearPendingStartInFlightLease(session, sessFront, stderr) - logLifecycleOutcome(stderr, "start", wave, name, tp.TemplateName, result.outcome, result.started, result.finished, nil, result.phases) + logLifecycleOutcome(stderr, "start", wave, name, tp.TemplateName, string(result.outcome), result.started, result.finished, nil, result.phases) return false } if result.err != nil { @@ -2033,7 +2033,7 @@ func commitStartResultTraced( "field": "started_config_hash", }) } - logLifecycleOutcome(stderr, "start", wave, name, tp.TemplateName, result.outcome, result.started, result.finished, nil, result.phases) + logLifecycleOutcome(stderr, "start", wave, name, tp.TemplateName, string(result.outcome), result.started, result.finished, nil, result.phases) return true } @@ -2052,7 +2052,7 @@ func commitStartFailure(result startResult, sessFront *sessionpkg.Store, clk clo fmt.Fprintf(stderr, "session reconciler: marking terminal provider error for %s: %v\n", name, err) //nolint:errcheck } if trace != nil { - trace.RecordOperation(TraceSiteLifecycleStartTerminalProviderError, TraceReasonStart, TraceOutcomeCode(result.outcome), "", tp.TemplateName, name, 0, traceRecordPayload{ + trace.RecordOperation(TraceSiteLifecycleStartTerminalProviderError, TraceReasonStart, result.outcome, "", tp.TemplateName, name, 0, traceRecordPayload{ "error": formatLifecycleError(result.err), "reason": reason, }) @@ -2060,7 +2060,7 @@ func commitStartFailure(result startResult, sessFront *sessionpkg.Store, clk clo if result.rollbackPending { rollbackPendingCreate(session, sessFront, clk.Now().UTC(), stderr) } - logLifecycleOutcome(stderr, "start", wave, name, tp.TemplateName, result.outcome, result.started, result.finished, result.err, result.phases) + logLifecycleOutcome(stderr, "start", wave, name, tp.TemplateName, string(result.outcome), result.started, result.finished, result.err, result.phases) return } if result.rateLimitScreen { @@ -2072,7 +2072,7 @@ func commitStartFailure(result startResult, sessFront *sessionpkg.Store, clk clo "cause": err.Error(), }) } - logLifecycleOutcome(stderr, "start", wave, name, tp.TemplateName, result.outcome, result.started, result.finished, result.err, result.phases) + logLifecycleOutcome(stderr, "start", wave, name, tp.TemplateName, string(result.outcome), result.started, result.finished, result.err, result.phases) return } if trace != nil { @@ -2080,7 +2080,7 @@ func commitStartFailure(result startResult, sessFront *sessionpkg.Store, clk clo "error": formatLifecycleError(result.err), }) } - logLifecycleOutcome(stderr, "start", wave, name, tp.TemplateName, result.outcome, result.started, result.finished, result.err, result.phases) + logLifecycleOutcome(stderr, "start", wave, name, tp.TemplateName, string(result.outcome), result.started, result.finished, result.err, result.phases) return } if result.rollbackPending { @@ -2098,12 +2098,12 @@ func commitStartFailure(result startResult, sessFront *sessionpkg.Store, clk clo // Genuine wake-failure accounting happens on the non-rollback path // below via recordWakeFailure. if trace != nil { - trace.RecordOperation(TraceSiteLifecycleStartRollback, TraceReasonStart, TraceOutcomeCode(result.outcome), "", tp.TemplateName, name, 0, traceRecordPayload{ + trace.RecordOperation(TraceSiteLifecycleStartRollback, TraceReasonStart, result.outcome, "", tp.TemplateName, name, 0, traceRecordPayload{ "error": formatLifecycleError(result.err), }) } rollbackPendingCreate(session, sessFront, clk.Now().UTC(), stderr) - logLifecycleOutcome(stderr, "start", wave, name, tp.TemplateName, result.outcome, result.started, result.finished, result.err, result.phases) + logLifecycleOutcome(stderr, "start", wave, name, tp.TemplateName, string(result.outcome), result.started, result.finished, result.err, result.phases) return } if err := sessFront.SetMarker(session.ID, "last_woke_at", ""); err != nil { @@ -2116,11 +2116,11 @@ func commitStartFailure(result startResult, sessFront *sessionpkg.Store, clk clo // even for a namepool-themed pool instance whose bead predates agent_name. recordWakeFailure(session, sessFront, clk, tp.DisplayName()) if trace != nil { - trace.RecordOperation(TraceSiteLifecycleStartFailed, TraceReasonStart, TraceOutcomeCode(result.outcome), "", tp.TemplateName, name, 0, traceRecordPayload{ + trace.RecordOperation(TraceSiteLifecycleStartFailed, TraceReasonStart, result.outcome, "", tp.TemplateName, name, 0, traceRecordPayload{ "error": formatLifecycleError(result.err), }) } - logLifecycleOutcome(stderr, "start", wave, name, tp.TemplateName, result.outcome, result.started, result.finished, result.err, result.phases) + logLifecycleOutcome(stderr, "start", wave, name, tp.TemplateName, string(result.outcome), result.started, result.finished, result.err, result.phases) } // recoverRunningPendingCreate heals an already-active bead whose @@ -2594,17 +2594,17 @@ func executePlannedStartsTraced( } for _, result := range results { if trace != nil { - trace.RecordOperation(TraceSiteLifecycleStartRun, TraceReasonStart, TraceOutcomeCode(result.outcome), "", result.prepared.candidate.tp.TemplateName, result.prepared.candidate.name(), result.finished.Sub(result.started), traceRecordPayload{ + trace.RecordOperation(TraceSiteLifecycleStartRun, TraceReasonStart, result.outcome, "", result.prepared.candidate.tp.TemplateName, result.prepared.candidate.name(), result.finished.Sub(result.started), traceRecordPayload{ "rollback_pending": result.rollbackPending, "duration_ms": result.finished.Sub(result.started).Milliseconds(), }) } - if result.outcome == "start_enqueued" { - logLifecycleOutcome(stderr, "start", wave, result.prepared.candidate.name(), result.prepared.candidate.logicalTemplate(cfg), result.outcome, result.started, result.finished, nil) + if result.outcome == TraceOutcomeStartEnqueued { + logLifecycleOutcome(stderr, "start", wave, result.prepared.candidate.name(), result.prepared.candidate.logicalTemplate(cfg), string(result.outcome), result.started, result.finished, nil) wakeCount++ continue } - if result.err == nil && result.outcome != "session_initializing" { + if result.err == nil && result.outcome != TraceOutcomeSessionInitializing { _ = clearReconcilerDrainAckMetadata(sp, result.prepared.candidate.name()) } if commitStartResultTraced(result, sessFront, clk, rec, wave, stdout, stderr, trace) { diff --git a/cmd/gc/session_reconciler.go b/cmd/gc/session_reconciler.go index 8db572c5a4..4c862ed4f7 100644 --- a/cmd/gc/session_reconciler.go +++ b/cmd/gc/session_reconciler.go @@ -71,6 +71,52 @@ func isDrainAckStopPending(session beads.Bead) bool { strings.TrimSpace(session.Metadata["state_reason"]) == sessionpkg.DrainAckStopPendingReason } +// timerTraceCodes maps a lifecycle-timer decision's trace reason/outcome onto +// the typed Trace*Code vocabulary. TimerDecision.TraceReason/TraceOutcome are +// plain strings owned by internal/session (Layer 0-1), which cannot import the +// cmd/gc trace types — so the conversion lives here at the projection boundary. +// The switches are exhaustive over the closed value sets that +// DecideMaxSessionAge and DecideIdleTimeout emit today; each default arm is an +// identity passthrough, so recorded bytes stay truthful even if a ladder grows +// a value before this map does. TestTimerTraceCodesTotal converts that drift +// into a red test rather than a silent un-typing. +func timerTraceCodes(dec sessionpkg.TimerDecision) (TraceReasonCode, TraceOutcomeCode) { + var reason TraceReasonCode + switch dec.TraceReason { + case string(TraceReasonMaxSessionAge): + reason = TraceReasonMaxSessionAge + case string(TraceReasonIdleTimeout): + reason = TraceReasonIdleTimeout + case string(TraceReasonUserHold): + reason = TraceReasonUserHold + case string(TraceReasonQuarantine): + reason = TraceReasonQuarantine + case string(TraceReasonPending): + reason = TraceReasonPending + case string(TraceReasonAssignedWork): + reason = TraceReasonAssignedWork + default: + reason = TraceReasonCode(dec.TraceReason) + } + + var outcome TraceOutcomeCode + switch dec.TraceOutcome { + case string(TraceOutcomeStop): + outcome = TraceOutcomeStop + case string(TraceOutcomeDeferredUserHold): + outcome = TraceOutcomeDeferredUserHold + case string(TraceOutcomeDeferredQuarantine): + outcome = TraceOutcomeDeferredQuarantine + case string(TraceOutcomeDeferredPending): + outcome = TraceOutcomeDeferredPending + case string(TraceOutcomeDeferredBusy): + outcome = TraceOutcomeDeferredBusy + default: + outcome = TraceOutcomeCode(dec.TraceOutcome) + } + return reason, outcome +} + // isDrainAckStopPendingInfo is the session.Info sibling of isDrainAckStopPending: // it reports whether a session is parked in the drain-ack stop-pending state from // the typed Info.MetadataState (raw "state") / Info.StateReason mirrors, with the @@ -1628,11 +1674,11 @@ func reconcileSessionBeadsTracedWithNamedDemand( if template == "" { template = info.Template } - result := "held" + result := TraceOutcomeHeld if rateLimitErr != nil { - result = "hold_deferred" + result = TraceOutcomeHoldDeferred } - trace.RecordDecision(TraceSiteReconcilerPreserveConfiguredNamed, TraceReasonRateLimit, TraceOutcomeCode(result), template, name, traceRecordPayload{ + trace.RecordDecision(TraceSiteReconcilerPreserveConfiguredNamed, TraceReasonRateLimit, result, template, name, traceRecordPayload{ "provider_alive": providerAlive, }) } @@ -1742,10 +1788,11 @@ func reconcileSessionBeadsTracedWithNamedDemand( desired = true } if trace != nil { - trace.RecordDecision(TraceSiteReconcilerPreserveConfiguredNamed, TraceReasonPreserve, TraceOutcomeCode(map[bool]string{ - true: "kept_open", - false: "resolution_failed", - }[desired]), template, name, traceRecordPayload{ + outcome := TraceOutcomeResolutionFailed + if desired { + outcome = TraceOutcomeKeptOpen + } + trace.RecordDecision(TraceSiteReconcilerPreserveConfiguredNamed, TraceReasonPreserve, outcome, template, name, traceRecordPayload{ "provider_alive": providerAlive, "degraded": preserveErr != nil, }) @@ -2907,12 +2954,14 @@ func reconcileSessionBeadsTracedWithNamedDemand( // by wake evaluation: bypass the max-age restart so SleepPatch // does not rewrite the intended sleep state. if trace != nil { - trace.RecordDecision(TraceSiteReconcilerMaxSessionAge, TraceReasonCode(dec.TraceReason), TraceOutcomeCode(dec.TraceOutcome), tp.TemplateName, name, nil) + reason, outcome := timerTraceCodes(dec) + trace.RecordDecision(TraceSiteReconcilerMaxSessionAge, reason, outcome, tp.TemplateName, name, nil) } case sessionpkg.TimerActionStop: fmt.Fprintf(stderr, "session reconciler: preemptive max-age restart for %s (age=%s)\n", tp.DisplayName(), clk.Now().Sub(creationCompleteAt).Round(time.Second)) //nolint:errcheck // best-effort stderr if trace != nil { - trace.RecordDecision(TraceSiteReconcilerMaxSessionAge, TraceReasonCode(dec.TraceReason), TraceOutcomeCode(dec.TraceOutcome), tp.TemplateName, name, nil) + reason, outcome := timerTraceCodes(dec) + trace.RecordDecision(TraceSiteReconcilerMaxSessionAge, reason, outcome, tp.TemplateName, name, nil) } if err := workerKillSessionTargetWithConfig("", store, sp, cfg, name); err != nil { fmt.Fprintf(stderr, "session reconciler: stopping aged %s: %v\n", name, err) //nolint:errcheck // best-effort stderr @@ -2988,7 +3037,8 @@ func reconcileSessionBeadsTracedWithNamedDemand( payload = traceRecordPayload{"drain_canceled": drainCancelled} } if trace != nil { - trace.RecordDecision(TraceSiteReconcilerIdleTimeout, TraceReasonCode(dec.TraceReason), TraceOutcomeCode(dec.TraceOutcome), tp.TemplateName, name, payload) + reason, outcome := timerTraceCodes(dec) + trace.RecordDecision(TraceSiteReconcilerIdleTimeout, reason, outcome, tp.TemplateName, name, payload) } if dec.SkipWakePass { continue @@ -2996,7 +3046,8 @@ func reconcileSessionBeadsTracedWithNamedDemand( case sessionpkg.TimerActionStop: fmt.Fprintf(stderr, "session reconciler: idle timeout for %s\n", tp.DisplayName()) //nolint:errcheck // best-effort stderr if trace != nil { - trace.RecordDecision(TraceSiteReconcilerIdleTimeout, TraceReasonCode(dec.TraceReason), TraceOutcomeCode(dec.TraceOutcome), tp.TemplateName, name, nil) + reason, outcome := timerTraceCodes(dec) + trace.RecordDecision(TraceSiteReconcilerIdleTimeout, reason, outcome, tp.TemplateName, name, nil) } if err := workerKillSessionTargetWithConfig("", store, sp, cfg, name); err != nil { fmt.Fprintf(stderr, "session reconciler: stopping idle %s: %v\n", name, err) //nolint:errcheck // best-effort stderr diff --git a/cmd/gc/session_reconciler_timer_trace_test.go b/cmd/gc/session_reconciler_timer_trace_test.go new file mode 100644 index 0000000000..e7cb3c621b --- /dev/null +++ b/cmd/gc/session_reconciler_timer_trace_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "testing" + + sessionpkg "github.com/gastownhall/gascity/internal/session" +) + +// TestTimerTraceCodesTotal drives every reachable TimerDecision from +// DecideMaxSessionAge and DecideIdleTimeout (all TimerFacts combinations, +// including both blocker kinds) and asserts that timerTraceCodes (a) maps each +// traced reason/outcome onto a NAMED constant — never falling through to the +// identity default arm — and (b) round-trips to the exact producer strings. +// When the timer ladders grow a new traced value, this test goes red instead +// of silently un-typing the vocabulary. +func TestTimerTraceCodesTotal(t *testing.T) { + namedReasons := map[TraceReasonCode]bool{ + TraceReasonMaxSessionAge: true, + TraceReasonIdleTimeout: true, + TraceReasonUserHold: true, + TraceReasonQuarantine: true, + TraceReasonPending: true, + TraceReasonAssignedWork: true, + } + namedOutcomes := map[TraceOutcomeCode]bool{ + TraceOutcomeStop: true, + TraceOutcomeDeferredUserHold: true, + TraceOutcomeDeferredQuarantine: true, + TraceOutcomeDeferredPending: true, + TraceOutcomeDeferredBusy: true, + } + + blockers := []string{"", "user_hold", "quarantine"} + pendings := []sessionpkg.PendingFact{ + sessionpkg.PendingUnknown, sessionpkg.PendingNo, sessionpkg.PendingYes, + } + assigned := []sessionpkg.AssignedWorkFact{ + sessionpkg.AssignedWorkUnknown, sessionpkg.AssignedWorkNone, sessionpkg.AssignedWorkHas, + } + + var decisions []sessionpkg.TimerDecision + for _, b := range blockers { + for _, p := range pendings { + for _, a := range assigned { + facts := sessionpkg.TimerFacts{Triggered: true, Blocker: b, Pending: p, AssignedWork: a} + decisions = append(decisions, sessionpkg.DecideMaxSessionAge(facts)) + decisions = append(decisions, sessionpkg.DecideIdleTimeout(facts)) + } + } + } + + sawTraced := false + for _, dec := range decisions { + // Only Defer/Stop decisions carry trace codes and reach a + // RecordDecision call site; gather/none actions leave them empty. + if dec.Action != sessionpkg.TimerActionDefer && dec.Action != sessionpkg.TimerActionStop { + continue + } + sawTraced = true + reason, outcome := timerTraceCodes(dec) + if string(reason) != dec.TraceReason { + t.Errorf("reason round-trip: got %q, want %q", string(reason), dec.TraceReason) + } + if string(outcome) != dec.TraceOutcome { + t.Errorf("outcome round-trip: got %q, want %q", string(outcome), dec.TraceOutcome) + } + if !namedReasons[reason] { + t.Errorf("reason %q fell through to the identity default arm (unnamed vocabulary)", string(reason)) + } + if !namedOutcomes[outcome] { + t.Errorf("outcome %q fell through to the identity default arm (unnamed vocabulary)", string(outcome)) + } + } + if !sawTraced { + t.Fatal("no traced TimerDecision exercised — enumeration is broken") + } +} diff --git a/cmd/gc/session_reconciler_trace_types.go b/cmd/gc/session_reconciler_trace_types.go index bffd316a70..a2d538de6a 100644 --- a/cmd/gc/session_reconciler_trace_types.go +++ b/cmd/gc/session_reconciler_trace_types.go @@ -190,6 +190,10 @@ const ( TraceReasonFreshCycle TraceReasonCode = "fresh_cycle" TraceReasonScaleCheck TraceReasonCode = "scale_check" TraceReasonStart TraceReasonCode = "start" + + TraceReasonMaxSessionAge TraceReasonCode = "max_session_age" + TraceReasonUserHold TraceReasonCode = "user_hold" + TraceReasonQuarantine TraceReasonCode = "quarantine" ) type TraceOutcomeCode string @@ -258,6 +262,14 @@ const ( TraceOutcomeHoldDeferred TraceOutcomeCode = "hold_deferred" TraceOutcomeHeld TraceOutcomeCode = "held" TraceOutcomeHealed TraceOutcomeCode = "healed" + + TraceOutcomeResolutionFailed TraceOutcomeCode = "resolution_failed" + TraceOutcomeStartErrorConverged TraceOutcomeCode = "start_error_converged" + TraceOutcomeSessionInitializing TraceOutcomeCode = "session_initializing" + TraceOutcomeStartEnqueued TraceOutcomeCode = "start_enqueued" + TraceOutcomeDeferredUserHold TraceOutcomeCode = "deferred_user_hold" + TraceOutcomeDeferredQuarantine TraceOutcomeCode = "deferred_quarantine" + TraceOutcomeDeferredBusy TraceOutcomeCode = "deferred_busy" ) type TraceCompletionStatus string diff --git a/cmd/gc/session_reconciler_trace_types_test.go b/cmd/gc/session_reconciler_trace_types_test.go new file mode 100644 index 0000000000..b684d8543d --- /dev/null +++ b/cmd/gc/session_reconciler_trace_types_test.go @@ -0,0 +1,35 @@ +package main + +import "testing" + +// TestTraceCodeConstantValues pins every trace-code constant added by S26b to +// its exact recorded string. A typo here would silently change the bytes that +// land in the trace JSONL (site_code/reason_code/outcome_code) — this test is +// the guard against that class of corruption. +func TestTraceCodeConstantValues(t *testing.T) { + reasons := map[TraceReasonCode]string{ + TraceReasonMaxSessionAge: "max_session_age", + TraceReasonUserHold: "user_hold", + TraceReasonQuarantine: "quarantine", + } + for got, want := range reasons { + if string(got) != want { + t.Errorf("reason constant = %q, want %q", string(got), want) + } + } + + outcomes := map[TraceOutcomeCode]string{ + TraceOutcomeResolutionFailed: "resolution_failed", + TraceOutcomeStartErrorConverged: "start_error_converged", + TraceOutcomeSessionInitializing: "session_initializing", + TraceOutcomeStartEnqueued: "start_enqueued", + TraceOutcomeDeferredUserHold: "deferred_user_hold", + TraceOutcomeDeferredQuarantine: "deferred_quarantine", + TraceOutcomeDeferredBusy: "deferred_busy", + } + for got, want := range outcomes { + if string(got) != want { + t.Errorf("outcome constant = %q, want %q", string(got), want) + } + } +} diff --git a/cmd/gc/session_wake.go b/cmd/gc/session_wake.go index 7962e69612..3a5ac02a20 100644 --- a/cmd/gc/session_wake.go +++ b/cmd/gc/session_wake.go @@ -629,20 +629,20 @@ func advanceSessionDrainsWithSessionsTraced( ds.followUp = true } if trace != nil { - outcome := "success" + outcome := TraceOutcomeSuccess fields := traceRecordPayload{ "reason": ds.reason, "deferred_signal": true, } if err != nil { - outcome = "failed" + outcome = TraceOutcomeFailed fields["error"] = err.Error() } fields["template"] = normalizedSessionTemplateInfo(info, cfg) fields["before"] = "" fields["after"] = "1" fields["field"] = "GC_DRAIN_ACK" - trace.RecordMutation(TraceSiteMutationRuntimeMeta, TraceReasonUnknown, TraceOutcomeCode(outcome), "provider_meta", name, "GC_DRAIN_ACK", fields) + trace.RecordMutation(TraceSiteMutationRuntimeMeta, TraceReasonUnknown, outcome, "provider_meta", name, "GC_DRAIN_ACK", fields) } } From 45a35983d59e83820bd3918900e74ed9b856e6fd Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 8 Jul 2026 10:41:48 -0700 Subject: [PATCH 002/225] simplify(S09b): table-driven Info codec + cmd/gc sleep-reason migration (#4062) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-on to merged S09 (#4033). Part 1 introduces a table-driven Info codec (parity-gated) and migrates cmd/gc sleep-reason literals to the codec. Part of #3789. - Gates green (fast suite, vet, pre-commit) - Fable-reviewed, behavior-preserved - Spec: engdocs/simplification/specs/S09b-info-codec-spec.md Spike/staged for review, not auto-merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/gc/city_runtime_test.go | 5 +- cmd/gc/cmd_stop.go | 4 +- cmd/gc/cmd_stop_test.go | 5 +- cmd/gc/cmd_wait.go | 2 +- cmd/gc/compute_awake_set.go | 4 +- cmd/gc/session_lifecycle_parallel.go | 4 +- cmd/gc/session_lifecycle_parallel_test.go | 6 +- cmd/gc/session_reconcile.go | 15 +- cmd/gc/session_reconcile_ratelimit_test.go | 5 +- cmd/gc/session_reconcile_test.go | 12 +- cmd/gc/session_reconciler.go | 4 +- cmd/gc/session_reconciler_test.go | 2 +- cmd/gc/session_sleep.go | 8 +- cmd/gc/session_state_helpers.go | 14 +- cmd/gc/session_state_helpers_test.go | 7 +- cmd/gc/session_wake.go | 4 +- internal/api/handler_beads.go | 5 +- internal/runproj/summary.go | 2 +- internal/runtime/t3bridge/provider.go | 9 +- internal/session/info_apply_patch.go | 200 +----------- internal/session/info_codec.go | 199 ++++++++++++ internal/session/info_codec_test.go | 357 +++++++++++++++++++++ internal/session/info_store.go | 142 ++------ 23 files changed, 646 insertions(+), 369 deletions(-) create mode 100644 internal/session/info_codec.go create mode 100644 internal/session/info_codec_test.go diff --git a/cmd/gc/city_runtime_test.go b/cmd/gc/city_runtime_test.go index 2544f396e6..3e1feab692 100644 --- a/cmd/gc/city_runtime_test.go +++ b/cmd/gc/city_runtime_test.go @@ -22,6 +22,7 @@ import ( "github.com/gastownhall/gascity/internal/orders" "github.com/gastownhall/gascity/internal/runtime" sessionauto "github.com/gastownhall/gascity/internal/runtime/auto" + sessionpkg "github.com/gastownhall/gascity/internal/session" ) type sweepLivenessProvider struct { @@ -771,8 +772,8 @@ func TestCityRuntimeShutdownMarksCityStopSleepReason(t *testing.T) { if err != nil { t.Fatalf("Get: %v", err) } - if got.Metadata["sleep_reason"] != sleepReasonCityStop { - t.Fatalf("sleep_reason = %q, want %q", got.Metadata["sleep_reason"], sleepReasonCityStop) + if got.Metadata["sleep_reason"] != string(sessionpkg.SleepReasonCityStop) { + t.Fatalf("sleep_reason = %q, want %q", got.Metadata["sleep_reason"], string(sessionpkg.SleepReasonCityStop)) } } diff --git a/cmd/gc/cmd_stop.go b/cmd/gc/cmd_stop.go index 517faad1a8..5cfcf245e5 100644 --- a/cmd/gc/cmd_stop.go +++ b/cmd/gc/cmd_stop.go @@ -54,8 +54,6 @@ straight to kill.`, var sessionProviderForStopCity = newSessionProviderForCity -const sleepReasonCityStop = "city-stop" - // cmdStop stops the city by terminating all configured agent sessions. // If a path is given, operates there; otherwise uses cwd. // @@ -366,7 +364,7 @@ func markCityStopSessionSleepReason(sessFront *session.Store, stderr io.Writer) if strings.TrimSpace(s.Metadata["sleep_reason"]) != "" { continue } - if err := sessFront.SetMarker(s.ID, "sleep_reason", sleepReasonCityStop); err != nil { + if err := sessFront.SetMarker(s.ID, "sleep_reason", string(session.SleepReasonCityStop)); err != nil { fmt.Fprintf(stderr, "gc stop: marking session %s: %v\n", s.ID, err) //nolint:errcheck // best-effort warning } } diff --git a/cmd/gc/cmd_stop_test.go b/cmd/gc/cmd_stop_test.go index f5d3897289..d4b96e4e4a 100644 --- a/cmd/gc/cmd_stop_test.go +++ b/cmd/gc/cmd_stop_test.go @@ -17,6 +17,7 @@ import ( "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/events" "github.com/gastownhall/gascity/internal/runtime" + sessionpkg "github.com/gastownhall/gascity/internal/session" ) type recordingStopProvider struct { @@ -921,8 +922,8 @@ func TestMarkCityStopSessionSleepReasonSkipsCreatingSessions(t *testing.T) { if err != nil { t.Fatal(err) } - if got := activeUpdated.Metadata["sleep_reason"]; got != sleepReasonCityStop { - t.Fatalf("active sleep_reason = %q, want %q", got, sleepReasonCityStop) + if got := activeUpdated.Metadata["sleep_reason"]; got != string(sessionpkg.SleepReasonCityStop) { + t.Fatalf("active sleep_reason = %q, want %q", got, string(sessionpkg.SleepReasonCityStop)) } creatingUpdated, err := store.Get(creating.ID) if err != nil { diff --git a/cmd/gc/cmd_wait.go b/cmd/gc/cmd_wait.go index cdb7c04233..6994c3a25c 100644 --- a/cmd/gc/cmd_wait.go +++ b/cmd/gc/cmd_wait.go @@ -1346,7 +1346,7 @@ func clearSessionWaitHold(sessFront *sessionpkg.Store, sessionID string) error { "sleep_intent": "", } if sessFront != nil { - if markers, err := sessFront.PersistedMarkers(sessionID); err == nil && markers.SleepReason == "wait-hold" { + if markers, err := sessFront.PersistedMarkers(sessionID); err == nil && markers.SleepReason == string(sessionpkg.SleepReasonWaitHold) { batch["sleep_reason"] = "" } } diff --git a/cmd/gc/compute_awake_set.go b/cmd/gc/compute_awake_set.go index 9962968728..f23b99d90d 100644 --- a/cmd/gc/compute_awake_set.go +++ b/cmd/gc/compute_awake_set.go @@ -430,7 +430,7 @@ func ComputeAwakeSet(input AwakeInput) map[string]AwakeDecision { // Drain-ack agents are unaffected — they manage their own // lifecycle by calling drain-ack before this check matters. if !decision.ShouldWake && !bead.Drained && !bead.WaitHold && - bead.SleepReason != "idle-timeout" { + bead.SleepReason != string(sessionpkg.SleepReasonIdleTimeout) { if input.RunningSessions[name] && isOnDemandSession(input.NamedSessions, bead) { decision.ShouldWake = true decision.Reason = "on-demand:running" @@ -645,7 +645,7 @@ func countMinActiveCovered(beads []AwakeSessionBead, desired map[string]string, func cityStopPoolBeads(beads []AwakeSessionBead, template string) []AwakeSessionBead { var out []AwakeSessionBead for _, b := range beads { - if isMinActivePoolBead(b, template) && b.State == "asleep" && b.SleepReason == "city-stop" { + if isMinActivePoolBead(b, template) && b.State == "asleep" && b.SleepReason == string(sessionpkg.SleepReasonCityStop) { out = append(out, b) } } diff --git a/cmd/gc/session_lifecycle_parallel.go b/cmd/gc/session_lifecycle_parallel.go index c9124655ef..4ac8234ea8 100644 --- a/cmd/gc/session_lifecycle_parallel.go +++ b/cmd/gc/session_lifecycle_parallel.go @@ -3052,14 +3052,14 @@ func cityStopSessionMarked(store beads.Store, sessionID string) bool { if err != nil { return false } - return strings.TrimSpace(b.Metadata["sleep_reason"]) == sleepReasonCityStop + return strings.TrimSpace(b.Metadata["sleep_reason"]) == string(sessionpkg.SleepReasonCityStop) } func markCityStopSessionAsAsleep(sessFront *sessionpkg.Store, sessionID string, stderr io.Writer) { if sessFront == nil || strings.TrimSpace(sessionID) == "" { return } - if err := sessFront.Sleep(sessionID, sleepReasonCityStop, time.Now().UTC()); err != nil && stderr != nil { + if err := sessFront.Sleep(sessionID, string(sessionpkg.SleepReasonCityStop), time.Now().UTC()); err != nil && stderr != nil { fmt.Fprintf(stderr, "gc stop: marking session %s asleep: %v\n", sessionID, err) //nolint:errcheck } } diff --git a/cmd/gc/session_lifecycle_parallel_test.go b/cmd/gc/session_lifecycle_parallel_test.go index b3112e1a00..d28221f855 100644 --- a/cmd/gc/session_lifecycle_parallel_test.go +++ b/cmd/gc/session_lifecycle_parallel_test.go @@ -6705,7 +6705,7 @@ func TestStopTargetThroughWorkerBoundary_CityStopLeavesSessionAsleep(t *testing. "session_name": "control-dispatcher", "template": "control-dispatcher", "state": "active", - "sleep_reason": sleepReasonCityStop, + "sleep_reason": string(sessionpkg.SleepReasonCityStop), }, }) if err != nil { @@ -6731,8 +6731,8 @@ func TestStopTargetThroughWorkerBoundary_CityStopLeavesSessionAsleep(t *testing. if got.Metadata["state"] != string(sessionpkg.StateAsleep) { t.Fatalf("state = %q, want %q", got.Metadata["state"], sessionpkg.StateAsleep) } - if got.Metadata["sleep_reason"] != sleepReasonCityStop { - t.Fatalf("sleep_reason = %q, want %q", got.Metadata["sleep_reason"], sleepReasonCityStop) + if got.Metadata["sleep_reason"] != string(sessionpkg.SleepReasonCityStop) { + t.Fatalf("sleep_reason = %q, want %q", got.Metadata["sleep_reason"], string(sessionpkg.SleepReasonCityStop)) } if got.Metadata["suspended_at"] != "" { t.Fatalf("suspended_at = %q, want empty", got.Metadata["suspended_at"]) diff --git a/cmd/gc/session_reconcile.go b/cmd/gc/session_reconcile.go index c36f9a78ca..8b34af72d1 100644 --- a/cmd/gc/session_reconcile.go +++ b/cmd/gc/session_reconcile.go @@ -39,13 +39,6 @@ type wakeEvaluation struct { HasAssignedWork bool } -const sleepReasonRuntimeMissing = "runtime-missing" - -// sleepReasonProviderTerminalError parks a session that hit a terminal -// (non-retryable) provider error. markProviderTerminalError writes it; the -// pool-slot freeable allowlist reads it to reap the dead bead + its worktree. -const sleepReasonProviderTerminalError = "provider-terminal-error" - const ( sessionHealthStateMetadataKey = "session_health" sessionHealthReasonMetadataKey = "session_health_reason" @@ -788,7 +781,7 @@ func markProviderTerminalError(session *beads.Bead, sessFront *sessionpkg.Store, } batch := map[string]string{ "state": string(sessionpkg.StateAsleep), - "sleep_reason": sleepReasonProviderTerminalError, + "sleep_reason": string(sessionpkg.SleepReasonProviderTerminalError), "last_woke_at": "", "pending_create_claim": "", "pending_create_started_at": "", @@ -1170,7 +1163,7 @@ func healStatePatchWithRollback(session beads.Bead, alive bool, clk clock.Clock, batch["state"] = string(sessionpkg.StateAsleep) } if strings.TrimSpace(meta["sleep_reason"]) == "" { - batch["sleep_reason"] = "drained" + batch["sleep_reason"] = string(sessionpkg.SleepReasonDrained) } return emptyNil(batch) } @@ -1230,12 +1223,12 @@ func healStatePatchWithRollback(session beads.Bead, alive bool, clk clock.Clock, if meta["state"] != target { batch["state"] = target if target == string(sessionpkg.StateAsleep) && (view.ResetContinuation || stalePendingCreateRollback) && strings.TrimSpace(meta["sleep_reason"]) == "" { - batch["sleep_reason"] = sleepReasonRuntimeMissing + batch["sleep_reason"] = string(sessionpkg.SleepReasonRuntimeMissing) } } if target == string(sessionpkg.StateAsleep) { if strings.TrimSpace(meta["sleep_reason"]) == "" && strings.TrimSpace(meta["state"]) == "failed-create" { - batch["sleep_reason"] = "failed-create" + batch["sleep_reason"] = string(sessionpkg.SleepReasonFailedCreate) } if view.ResetContinuation || stalePendingCreateRollback { if !isNamedSessionBead(session) || namedSessionMode(session) != "always" { diff --git a/cmd/gc/session_reconcile_ratelimit_test.go b/cmd/gc/session_reconcile_ratelimit_test.go index 600eae6c68..f12c85e64c 100644 --- a/cmd/gc/session_reconcile_ratelimit_test.go +++ b/cmd/gc/session_reconcile_ratelimit_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/gastownhall/gascity/internal/clock" + sessionpkg "github.com/gastownhall/gascity/internal/session" ) // TestCheckStability_RateLimitScreen_DoesNotCountAsCrash pins the desired @@ -379,8 +380,8 @@ func TestCheckStability_TerminalErrorScreen_MarksTerminalNotCrash(t *testing.T) if got := session.Metadata["state"]; got != "asleep" { t.Errorf("state = %q, want asleep", got) } - if got := session.Metadata["sleep_reason"]; got != sleepReasonProviderTerminalError { - t.Errorf("sleep_reason = %q, want %q", got, sleepReasonProviderTerminalError) + if got := session.Metadata["sleep_reason"]; got != string(sessionpkg.SleepReasonProviderTerminalError) { + t.Errorf("sleep_reason = %q, want %q", got, string(sessionpkg.SleepReasonProviderTerminalError)) } if got := session.Metadata[sessionProviderTerminalErrorMetadataKey]; got != "model_not_found" { t.Errorf("%s = %q, want model_not_found", sessionProviderTerminalErrorMetadataKey, got) diff --git a/cmd/gc/session_reconcile_test.go b/cmd/gc/session_reconcile_test.go index 5c0fe2ccc3..d4f8148670 100644 --- a/cmd/gc/session_reconcile_test.go +++ b/cmd/gc/session_reconcile_test.go @@ -1752,8 +1752,8 @@ func TestHealState_StaleCreatingPendingClaimDoesNotOscillateBackToCreating(t *te if got := session.Metadata["state"]; got != "asleep" { t.Fatalf("after first heal: state = %q, want asleep", got) } - if got := session.Metadata["sleep_reason"]; got != sleepReasonRuntimeMissing { - t.Fatalf("after first heal: sleep_reason = %q, want %q", got, sleepReasonRuntimeMissing) + if got := session.Metadata["sleep_reason"]; got != string(sessionpkg.SleepReasonRuntimeMissing) { + t.Fatalf("after first heal: sleep_reason = %q, want %q", got, string(sessionpkg.SleepReasonRuntimeMissing)) } if got := session.Metadata["pending_create_claim"]; got != "" { t.Fatalf("after first heal: pending_create_claim = %q, want empty", got) @@ -1894,7 +1894,7 @@ func TestHealStatePatchProjectsRuntimeLiveness(t *testing.T) { }(), want: map[string]string{ "state": "asleep", - "sleep_reason": sleepReasonRuntimeMissing, + "sleep_reason": string(sessionpkg.SleepReasonRuntimeMissing), "session_key": "", "started_config_hash": "", "continuation_reset_pending": "true", @@ -1973,7 +1973,7 @@ func TestHealStatePatchProjectsRuntimeLiveness(t *testing.T) { }(), want: map[string]string{ "state": "asleep", - "sleep_reason": sleepReasonRuntimeMissing, + "sleep_reason": string(sessionpkg.SleepReasonRuntimeMissing), "session_key": "", "started_config_hash": "", "continuation_reset_pending": "true", @@ -2165,7 +2165,7 @@ func TestHealState_ClearsStaleResumeMetadata(t *testing.T) { { name: "city stop — resume metadata preserved", prevState: "active", - sleepReason: sleepReasonCityStop, + sleepReason: string(sessionpkg.SleepReasonCityStop), sessionKey: "abc-123", startedConfigHash: "hash-before", wantKeyCleared: false, @@ -2717,7 +2717,7 @@ func TestCheckChurn_CityStopSleepReasonSkipped(t *testing.T) { session := makeBead("b1", map[string]string{ "last_woke_at": now.Add(-90 * time.Second).Format(time.RFC3339), - "sleep_reason": sleepReasonCityStop, + "sleep_reason": string(sessionpkg.SleepReasonCityStop), "churn_count": "0", "session_key": "resume-key", "continuation_reset_pending": "", diff --git a/cmd/gc/session_reconciler.go b/cmd/gc/session_reconciler.go index 4c862ed4f7..1f7fd203bf 100644 --- a/cmd/gc/session_reconciler.go +++ b/cmd/gc/session_reconciler.go @@ -511,7 +511,7 @@ func finalizeDrainAckStoppedSession( } batch := sessionpkg.AcknowledgeDrainPatch(info.WakeMode == "fresh") if hasAssignedWork { - batch = sessionpkg.CompleteDrainPatch(clk.Now().UTC(), "idle", info.WakeMode == "fresh") + batch = sessionpkg.CompleteDrainPatch(clk.Now().UTC(), string(sessionpkg.SleepReasonIdle), info.WakeMode == "fresh") } // A drain-ack that completes a restart-request cycle (gc session reset → // agent drain-ack) must also consume restart_requested. The drain-ack @@ -2475,7 +2475,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // the same SleepPatch reproduces the mirror exactly (slept_at / // sleep_policy_fingerprint are non-Info). Pre-pass-masked (STEP6-PREPASS-AUDIT // group 6). - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(sessionpkg.SleepPatch(clk.Now().UTC(), "idle")) + infoByID[session.ID] = infoByID[session.ID].ApplyPatch(sessionpkg.SleepPatch(clk.Now().UTC(), string(sessionpkg.SleepReasonIdle))) } // Fold detached_at change onto the snapshot (Step 6d write-returns-Info). // reconcileDetachedAt returns the {"detached_at": } batch it mirrored, diff --git a/cmd/gc/session_reconciler_test.go b/cmd/gc/session_reconciler_test.go index 30dd00a45d..30e6e4bb3c 100644 --- a/cmd/gc/session_reconciler_test.go +++ b/cmd/gc/session_reconciler_test.go @@ -2837,7 +2837,7 @@ func TestReconcileSessionBeads_CloseGatePreservesSleepReason(t *testing.T) { }{ {"idle", "idle", "idle"}, {"idle-timeout", "idle-timeout", "idle-timeout"}, - {"city-stop", sleepReasonCityStop, sleepReasonCityStop}, + {"city-stop", string(sessionpkg.SleepReasonCityStop), string(sessionpkg.SleepReasonCityStop)}, {"drained-reason", "drained", "drained"}, {"missing-reason", "", "drained"}, // fallback } diff --git a/cmd/gc/session_sleep.go b/cmd/gc/session_sleep.go index f1d5a353e4..4cfcec5b72 100644 --- a/cmd/gc/session_sleep.go +++ b/cmd/gc/session_sleep.go @@ -224,10 +224,10 @@ func configWakeSuppressed( if !policy.enabled() { return false } - if session.Metadata["sleep_reason"] == "idle-timeout" { + if session.Metadata["sleep_reason"] == string(sessionpkg.SleepReasonIdleTimeout) { return false } - if session.Metadata["sleep_reason"] == "idle" && + if session.Metadata["sleep_reason"] == string(sessionpkg.SleepReasonIdle) && session.Metadata["sleep_policy_fingerprint"] != "" && session.Metadata["sleep_policy_fingerprint"] == policy.Fingerprint { return true @@ -271,7 +271,7 @@ func persistSleepPolicyMetadata( } fingerprint := policy.Fingerprint if ((session.Metadata["state"] == "asleep" && - session.Metadata["sleep_reason"] == "idle") || + session.Metadata["sleep_reason"] == string(sessionpkg.SleepReasonIdle)) || session.Metadata["sleep_intent"] == "idle-stop-pending") && session.Metadata["sleep_policy_fingerprint"] != "" { // Preserve the fingerprint that initiated an in-flight idle drain so the @@ -336,7 +336,7 @@ func recoverPendingIdleSleep( if session == nil || sessFront == nil || running || session.Metadata["sleep_intent"] != "idle-stop-pending" { return false } - batch := sessionpkg.SleepPatch(clk.Now(), "idle") + batch := sessionpkg.SleepPatch(clk.Now(), string(sessionpkg.SleepReasonIdle)) if fingerprint := session.Metadata["sleep_policy_fingerprint"]; fingerprint != "" { batch["sleep_policy_fingerprint"] = fingerprint } diff --git a/cmd/gc/session_state_helpers.go b/cmd/gc/session_state_helpers.go index f7c6cfd19a..428b8ddff9 100644 --- a/cmd/gc/session_state_helpers.go +++ b/cmd/gc/session_state_helpers.go @@ -12,7 +12,7 @@ func isDrainedSessionMetadata(meta map[string]string) bool { if state == "drained" { return true } - return state == "asleep" && strings.TrimSpace(meta["sleep_reason"]) == "drained" + return state == "asleep" && strings.TrimSpace(meta["sleep_reason"]) == string(sessionpkg.SleepReasonDrained) } func isDrainedSessionBead(session beads.Bead) bool { @@ -27,7 +27,7 @@ func isDrainedSessionInfo(i sessionpkg.Info) bool { if state == "drained" { return true } - return state == "asleep" && strings.TrimSpace(i.SleepReason) == "drained" + return state == "asleep" && strings.TrimSpace(i.SleepReason) == string(sessionpkg.SleepReasonDrained) } // poolSessionIsLive reports whether a pool session bead represents an @@ -75,8 +75,9 @@ func isPoolSessionSlotFreeable(session beads.Bead) bool { } reason := strings.TrimSpace(session.Metadata["sleep_reason"]) switch reason { - case "idle", "idle-timeout", sleepReasonCityStop, "failed-create", sleepReasonRuntimeMissing, - sleepReasonProviderTerminalError: + case string(sessionpkg.SleepReasonIdle), string(sessionpkg.SleepReasonIdleTimeout), + string(sessionpkg.SleepReasonCityStop), string(sessionpkg.SleepReasonFailedCreate), + string(sessionpkg.SleepReasonRuntimeMissing), string(sessionpkg.SleepReasonProviderTerminalError): return true } return false @@ -92,8 +93,9 @@ func isPoolSessionSlotFreeableInfo(i sessionpkg.Info) bool { } reason := strings.TrimSpace(i.SleepReason) switch reason { - case "idle", "idle-timeout", sleepReasonCityStop, "failed-create", sleepReasonRuntimeMissing, - sleepReasonProviderTerminalError: + case string(sessionpkg.SleepReasonIdle), string(sessionpkg.SleepReasonIdleTimeout), + string(sessionpkg.SleepReasonCityStop), string(sessionpkg.SleepReasonFailedCreate), + string(sessionpkg.SleepReasonRuntimeMissing), string(sessionpkg.SleepReasonProviderTerminalError): return true } return false diff --git a/cmd/gc/session_state_helpers_test.go b/cmd/gc/session_state_helpers_test.go index 06a863e553..e0a5081d22 100644 --- a/cmd/gc/session_state_helpers_test.go +++ b/cmd/gc/session_state_helpers_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/gastownhall/gascity/internal/beads" + sessionpkg "github.com/gastownhall/gascity/internal/session" ) // TestPoolSessionIsLive_Matrix exercises the liveness predicate used by the @@ -52,10 +53,10 @@ func TestIsPoolSessionSlotFreeable_Matrix(t *testing.T) { {"asleep+drained-reason", map[string]string{"state": "asleep", "sleep_reason": "drained"}, true}, {"asleep+idle", map[string]string{"state": "asleep", "sleep_reason": "idle"}, true}, {"asleep+idle-timeout", map[string]string{"state": "asleep", "sleep_reason": "idle-timeout"}, true}, - {"asleep+city-stop", map[string]string{"state": "asleep", "sleep_reason": sleepReasonCityStop}, true}, + {"asleep+city-stop", map[string]string{"state": "asleep", "sleep_reason": string(sessionpkg.SleepReasonCityStop)}, true}, {"asleep+failed-create", map[string]string{"state": "asleep", "sleep_reason": "failed-create"}, true}, - {"asleep+runtime-missing", map[string]string{"state": "asleep", "sleep_reason": sleepReasonRuntimeMissing}, true}, - {"asleep+provider-terminal-error", map[string]string{"state": "asleep", "sleep_reason": sleepReasonProviderTerminalError}, true}, + {"asleep+runtime-missing", map[string]string{"state": "asleep", "sleep_reason": string(sessionpkg.SleepReasonRuntimeMissing)}, true}, + {"asleep+provider-terminal-error", map[string]string{"state": "asleep", "sleep_reason": string(sessionpkg.SleepReasonProviderTerminalError)}, true}, {"asleep+empty-reason", map[string]string{"state": "asleep", "sleep_reason": ""}, false}, {"asleep+missing-reason", map[string]string{"state": "asleep"}, false}, {"asleep+wait-hold", map[string]string{"state": "asleep", "sleep_reason": "wait-hold"}, false}, diff --git a/cmd/gc/session_wake.go b/cmd/gc/session_wake.go index 3a5ac02a20..b0cf71fb84 100644 --- a/cmd/gc/session_wake.go +++ b/cmd/gc/session_wake.go @@ -50,10 +50,10 @@ func preWakeCommit( } sleepReason := "" - if session.Metadata["sleep_reason"] == "idle-timeout" { + if session.Metadata["sleep_reason"] == string(sessions.SleepReasonIdleTimeout) { // Preserve the idle-timeout wake override until the replacement // session has actually started. Failed starts must retry next tick. - sleepReason = "idle-timeout" + sleepReason = string(sessions.SleepReasonIdleTimeout) } freshWake := session.Metadata["wake_mode"] == "fresh" || pendingContinuationResetNeedsFreshStart(session.Metadata) diff --git a/internal/api/handler_beads.go b/internal/api/handler_beads.go index 15fcd5e1c4..5e78c263ca 100644 --- a/internal/api/handler_beads.go +++ b/internal/api/handler_beads.go @@ -26,7 +26,10 @@ func appendMetadataAttachedChildren(store beads.Store, parent beads.Bead, childr for _, child := range children { seen[child.ID] = struct{}{} } - for _, key := range []string{"molecule_id", "workflow_id"} { + // NOTE: "workflow_id" is the bare (non-prefixed) metadata key, distinct + // from beadmeta.WorkflowIDMetadataKey ("gc.workflow_id") — do NOT substitute + // the prefixed constant here or this would surface a different key. + for _, key := range []string{beadmeta.MoleculeIDMetadataKey, "workflow_id"} { attachedID := strings.TrimSpace(parent.Metadata[key]) if attachedID == "" { continue diff --git a/internal/runproj/summary.go b/internal/runproj/summary.go index feaae75a69..17cec76133 100644 --- a/internal/runproj/summary.go +++ b/internal/runproj/summary.go @@ -272,7 +272,7 @@ func runRootID(issue runIssue) string { if stringValue(md[beadmeta.KindMetadataKey]) == "run" || issue.issueType == "molecule" { return issue.id } - if moleculeID := stringValue(md["molecule_id"]); moleculeID != "" { + if moleculeID := stringValue(md[beadmeta.MoleculeIDMetadataKey]); moleculeID != "" { return moleculeID } return issue.id diff --git a/internal/runtime/t3bridge/provider.go b/internal/runtime/t3bridge/provider.go index f3233e9fb7..afa045bc85 100644 --- a/internal/runtime/t3bridge/provider.go +++ b/internal/runtime/t3bridge/provider.go @@ -20,6 +20,7 @@ import ( "sync" "time" + "github.com/gastownhall/gascity/internal/beadmeta" "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/events" "github.com/gastownhall/gascity/internal/runtime" @@ -1731,7 +1732,7 @@ func activityFromBeadEvent(ev events.Event, bead beads.Bead) (string, string, ma "beadStatus": bead.Status, "assignee": bead.Assignee, "formula": bead.Ref, - "moleculeId": bead.Metadata["molecule_id"], + "moleculeId": bead.Metadata[beadmeta.MoleculeIDMetadataKey], "eventType": ev.Type, } case ev.Type == events.BeadUpdated: @@ -1745,7 +1746,7 @@ func activityFromBeadEvent(ev events.Event, bead beads.Bead) (string, string, ma "beadStatus": bead.Status, "assignee": bead.Assignee, "formula": bead.Ref, - "moleculeId": bead.Metadata["molecule_id"], + "moleculeId": bead.Metadata[beadmeta.MoleculeIDMetadataKey], "eventType": ev.Type, } default: @@ -1755,7 +1756,7 @@ func activityFromBeadEvent(ev events.Event, bead beads.Bead) (string, string, ma "beadStatus": bead.Status, "assignee": bead.Assignee, "formula": bead.Ref, - "moleculeId": bead.Metadata["molecule_id"], + "moleculeId": bead.Metadata[beadmeta.MoleculeIDMetadataKey], "eventType": ev.Type, } } @@ -1796,7 +1797,7 @@ func (p *Provider) refreshAssignmentProjection(threadID string, envelope Startup next.Assignment.ConvoyTotalCount = convoyTotalCount next.Assignment.Formula = bead.Ref if next.Assignment.MoleculeID == "" { - next.Assignment.MoleculeID = bead.Metadata["molecule_id"] + next.Assignment.MoleculeID = bead.Metadata[beadmeta.MoleculeIDMetadataKey] } _ = p.dispatchThreadMeta(threadID, buildGCMetadata(next, providerName, nil)) } diff --git a/internal/session/info_apply_patch.go b/internal/session/info_apply_patch.go index aa7356465f..61c6a748ba 100644 --- a/internal/session/info_apply_patch.go +++ b/internal/session/info_apply_patch.go @@ -1,13 +1,5 @@ package session -import ( - "strconv" - "strings" - "time" - - "github.com/gastownhall/gascity/internal/beadmeta" -) - // ApplyPatch returns a copy of info with a MetadataPatch applied to its // metadata-derived fields. It is the typed "write-returns-Info" half of the // session front door (front-door migration Step 6d): the reconciler applies a @@ -30,187 +22,21 @@ import ( // a metadata patch — a status close is a separate refresh case (Store.Get) — // so ApplyPatch reads the carried-forward Closed and never flips it. // -// The mapping is deliberately parallel to InfoFromPersistedBead; -// TestInfoApplyPatchMatchesReprojection is the equivalence oracle that guards -// the two against drift, exactly as TestSessionClassifierInfoEquivalence guards -// the classifier siblings. +// The fold shares one codec table with InfoFromPersistedBead (info_codec.go): +// each key's setter is the SAME closure both directions run, so fold == +// re-projection by construction. TestInfoApplyPatchMatchesReprojection is kept +// as the equivalence oracle that gates the two against drift, exactly as +// TestSessionClassifierInfoEquivalence guards the classifier siblings. func (info Info) ApplyPatch(patch MetadataPatch) Info { for key, v := range patch { - switch key { - case "session_name": - info.SessionNameMetadata = v - if v == "" { - info.SessionName = sessionNameFor(info.ID) - } else { - info.SessionName = v - } - case "state": - info.MetadataState = v - if info.Closed { - info.State = "" // closed beads have no runtime state - } else { - info.State = normalizeInfoState(State(v)) - } - case "template": - info.Template = v - case "alias": - info.Alias = v - case "agent_name": - info.AgentName = v - case "provider": - info.Provider = v - info.Transport = normalizeTransport(v, info.TransportMetadata) - case "transport": - info.TransportMetadata = v - info.Transport = normalizeTransport(info.Provider, v) - case "command": - info.Command = v - case "work_dir": - info.WorkDir = v - case "session_key": - info.SessionKey = v - case "resume_flag": - info.ResumeFlag = v - case "resume_style": - info.ResumeStyle = v - case "resume_command": - info.ResumeCommand = v - case "continuation_epoch": - info.ContinuationEpoch = v - case "sleep_reason": - info.SleepReason = v - case NamedSessionIdentityMetadata: - info.ConfiguredNamedIdentity = v - case NamedSessionMetadataKey: - info.ConfiguredNamedSession = strings.TrimSpace(v) == "true" - case NamedSessionModeMetadata: - info.ConfiguredNamedMode = v - case "common_name": - info.CommonName = v - case "pool_slot": - info.PoolSlot = v - case "pool_managed": - info.PoolManaged = strings.TrimSpace(v) == "true" - case "session_origin": - info.SessionOrigin = v - case "dependency_only": - info.DependencyOnly = strings.TrimSpace(v) == "true" - info.DependencyOnlyMetadata = v - case "manual_session": - info.ManualSession = strings.TrimSpace(v) == "true" - info.ManualSessionMetadata = v - case MCPIdentityMetadataKey: - info.MCPIdentity = v - case MCPServersSnapshotMetadataKey: - info.MCPServersSnapshot = v - case "provider_terminal_error": - info.ProviderTerminalError = v - case "session_health": - info.HealthState = v - case "session_health_reason": - info.HealthReason = v - case "session_drainable": - info.Drainable = strings.TrimSpace(v) == "true" - case beadmeta.TriggerBeadIDMetadataKey: - info.TriggerBeadID = v - case beadmeta.TriggerBeadStoreRefMetadataKey: - info.TriggerBeadStoreRef = v - case beadmeta.BrainParentSIDMetadataKey: - info.BrainParentSID = v - case beadmeta.PackMetadataKey: - info.Pack = v - case "pending_create_claim": - info.PendingCreateClaim = strings.TrimSpace(v) == "true" - info.PendingCreateClaimMetadata = v - case "pending_create_started_at": - info.PendingCreateStartedAt = v - case "quarantined_until": - info.QuarantinedUntil = v - case aliasHistoryMetadataKey: - info.AliasHistory = normalizeAliasList(strings.Split(v, ","), "") - case "continuity_eligible": - info.ContinuityEligible = v - case "last_woke_at": - info.LastWokeAt = v - case "state_reason": - info.StateReason = v - case "creation_complete_at": - info.CreationCompleteAt = v - case "continuation_reset_pending": - info.ContinuationResetPending = v - case ResetCommittedAtKey: - info.ResetCommittedAt = v - case "generation": - info.Generation = v - case "started_config_hash": - info.StartedConfigHash = v - case "pin_awake": - info.PinAwake = v - case "held_until": - info.HeldUntil = v - case "wait_hold": - info.WaitHold = v - case "churn_count": - info.ChurnCount = v - case "wake_mode": - info.WakeMode = v - case "sleep_intent": - info.SleepIntent = v - case "instance_token": - info.InstanceToken = v - case "detached_at": - info.DetachedAt = v - case CurrentBeadIDKey: - info.CurrentlyProcessingBeadID = v - case "core_hash_breakdown": - info.CoreHashBreakdown = v - case "started_provision_hash": - info.StartedProvisionHash = v - case "started_launch_hash": - info.StartedLaunchHash = v - case "started_live_hash": - info.StartedLiveHash = v - case "config_drift_deferred_at": - info.ConfigDriftDeferredAt = v - case "config_drift_deferred_key": - info.ConfigDriftDeferredKey = v - case "attached_config_drift_deferred_at": - info.AttachedConfigDriftDeferredAt = v - case "attached_config_drift_deferred_key": - info.AttachedConfigDriftDeferredKey = v - case "stranded_event_emitted_at": - info.StrandedEventEmittedAt = v - case "session_name_explicit": - info.SessionNameExplicit = v - case "wake_request": - info.WakeRequest = v - case "restart_requested": - info.RestartRequested = v - case "session_id_flag": - info.SessionIDFlag = v - case "template_overrides": - info.TemplateOverrides = v - case "wake_attempts": - info.WakeAttemptsMetadata = v - if n, err := strconv.Atoi(v); err == nil { - info.WakeAttempts = n - } else { - info.WakeAttempts = 0 - } - case "provider_kind": - info.ProviderKind = v - case MetadataLastNudgeDeliveredAt: - info.LastNudgeDeliveredAt = time.Time{} - if raw := strings.TrimSpace(v); raw != "" { - if parsed, err := time.Parse(time.RFC3339, raw); err == nil { - info.LastNudgeDeliveredAt = parsed - } - } - default: - // Keys InfoFromPersistedBead does not project (e.g. live_hash, - // startup_dialog_verified, env.*) have no Info field, so a patch to - // them changes no Info fact. Ignoring them keeps ApplyPatch - // byte-identical to a full re-projection. + // A key InfoFromPersistedBead projects folds through its shared codec + // setter — the SAME closure the projection runs — so the fold is a + // re-projection of that one key by construction. Keys the projection + // does not read (e.g. live_hash, startup_dialog_verified, env.*) miss + // the index and carry no Info field, keeping ApplyPatch byte-identical + // to a full re-projection. + if spec, ok := infoKeyIndex[key]; ok { + spec.set(&info, v) } } return info diff --git a/internal/session/info_codec.go b/internal/session/info_codec.go new file mode 100644 index 0000000000..3b7804d772 --- /dev/null +++ b/internal/session/info_codec.go @@ -0,0 +1,199 @@ +package session + +import ( + "strconv" + "strings" + "time" + + "github.com/gastownhall/gascity/internal/beadmeta" +) + +// infoKeySpec is one metadata key's codec: how a raw metadata value becomes +// Info fields. The SAME closure drives both directions of the metadata⇄Info +// codec — projection (InfoFromPersistedBead) and fold (Info.ApplyPatch) — so a +// fold is a re-projection of that one key by construction, and the two can no +// longer drift apart. +// +// Contract of set: it is total over the empty string and correct ONLY when +// applied to a fresh (zero-valued) Info in projection order, OR folded onto a +// coherent Info snapshot in patch semantics. In both regimes an absent key +// reads as "" and every setter produces the correctly-cleared state for "". +// Do not reuse a setter against an arbitrary partially-mutated Info outside +// those two regimes — the projection/patch equivalence assumes one of them. +type infoKeySpec struct { + key string // exact on-store metadata key (byte-identical to today) + set func(info *Info, v string) // typed setter: writes ALL Info fields derived from this key +} + +// infoKeyCodec is the single source of truth for the metadata-derived half of +// the Info projection. Ordering is irrelevant for correctness EXCEPT for two +// documented dependencies (invariant I6): +// - the bead-level prologue (ID, Closed, …) in InfoFromPersistedBead runs +// before this table, because session_name reads info.ID and state reads +// info.Closed; +// - provider is listed before transport, so both raw mirrors are in scope +// when the derived Transport is finalized (they converge to the same +// value in either order — see the provider/transport entries). +// +// Every other pair of entries writes a disjoint set of Info fields (asserted by +// TestInfoCodecFieldsDisjoint). The clustering mirrors the old struct literal +// so review provenance survives. +var infoKeyCodec = []infoKeySpec{ + // core / identity cluster + {"template", func(i *Info, v string) { i.Template = v }}, + {"alias", func(i *Info, v string) { i.Alias = v }}, + {"agent_name", func(i *Info, v string) { i.AgentName = v }}, + {"command", func(i *Info, v string) { i.Command = v }}, + {"work_dir", func(i *Info, v string) { i.WorkDir = v }}, + {"session_key", func(i *Info, v string) { i.SessionKey = v }}, + {"resume_flag", func(i *Info, v string) { i.ResumeFlag = v }}, + {"resume_style", func(i *Info, v string) { i.ResumeStyle = v }}, + {"resume_command", func(i *Info, v string) { i.ResumeCommand = v }}, + {"continuation_epoch", func(i *Info, v string) { i.ContinuationEpoch = v }}, + {"sleep_reason", func(i *Info, v string) { i.SleepReason = v }}, + + // session_name: fallback-defaulted; reads info.ID (set in the prologue). + {"session_name", func(i *Info, v string) { + i.SessionNameMetadata = v + if v == "" { + i.SessionName = sessionNameFor(i.ID) + } else { + i.SessionName = v + } + }}, + + // state: normalize + closed-blank; reads info.Closed (set in the prologue). + {"state", func(i *Info, v string) { + i.MetadataState = v + if i.Closed { + i.State = "" // closed beads have no runtime state + } else { + i.State = normalizeInfoState(State(v)) + } + }}, + + // provider/transport cross-field pair: each setter re-derives Transport from + // the sibling's current raw mirror. provider MUST precede transport so both + // raw values are in scope when Transport is finalized; the two converge to + // normalizeTransport(provider, transport) regardless of arrival order. + {"provider", func(i *Info, v string) { + i.Provider = v + i.Transport = normalizeTransport(v, i.TransportMetadata) + }}, + {"transport", func(i *Info, v string) { + i.TransportMetadata = v + i.Transport = normalizeTransport(i.Provider, v) + }}, + + // identity / pool / named-session cluster + {NamedSessionIdentityMetadata, func(i *Info, v string) { i.ConfiguredNamedIdentity = v }}, + {NamedSessionMetadataKey, func(i *Info, v string) { i.ConfiguredNamedSession = strings.TrimSpace(v) == "true" }}, + {NamedSessionModeMetadata, func(i *Info, v string) { i.ConfiguredNamedMode = v }}, + {"common_name", func(i *Info, v string) { i.CommonName = v }}, + {"pool_slot", func(i *Info, v string) { i.PoolSlot = v }}, + {"pool_managed", func(i *Info, v string) { i.PoolManaged = strings.TrimSpace(v) == "true" }}, + {"session_origin", func(i *Info, v string) { i.SessionOrigin = v }}, + {"dependency_only", func(i *Info, v string) { + i.DependencyOnly = strings.TrimSpace(v) == "true" + i.DependencyOnlyMetadata = v + }}, + {"manual_session", func(i *Info, v string) { + i.ManualSession = strings.TrimSpace(v) == "true" + i.ManualSessionMetadata = v + }}, + {MCPIdentityMetadataKey, func(i *Info, v string) { i.MCPIdentity = v }}, + {MCPServersSnapshotMetadataKey, func(i *Info, v string) { i.MCPServersSnapshot = v }}, + + // health / provider-terminal-error cluster + {"provider_terminal_error", func(i *Info, v string) { i.ProviderTerminalError = v }}, + {"session_health", func(i *Info, v string) { i.HealthState = v }}, + {"session_health_reason", func(i *Info, v string) { i.HealthReason = v }}, + {"session_drainable", func(i *Info, v string) { i.Drainable = strings.TrimSpace(v) == "true" }}, + + // trigger / brain-parent cluster (canonical gc.* keys via beadmeta) + {beadmeta.TriggerBeadIDMetadataKey, func(i *Info, v string) { i.TriggerBeadID = v }}, + {beadmeta.TriggerBeadStoreRefMetadataKey, func(i *Info, v string) { i.TriggerBeadStoreRef = v }}, + {beadmeta.BrainParentSIDMetadataKey, func(i *Info, v string) { i.BrainParentSID = v }}, + {beadmeta.PackMetadataKey, func(i *Info, v string) { i.Pack = v }}, + + // state / bookkeeping cluster + {"pending_create_claim", func(i *Info, v string) { + i.PendingCreateClaim = strings.TrimSpace(v) == "true" + i.PendingCreateClaimMetadata = v + }}, + {"pending_create_started_at", func(i *Info, v string) { i.PendingCreateStartedAt = v }}, + {"quarantined_until", func(i *Info, v string) { i.QuarantinedUntil = v }}, + {aliasHistoryMetadataKey, func(i *Info, v string) { + i.AliasHistory = normalizeAliasList(strings.Split(v, ","), "") + }}, + {"continuity_eligible", func(i *Info, v string) { i.ContinuityEligible = v }}, + {"last_woke_at", func(i *Info, v string) { i.LastWokeAt = v }}, + {"state_reason", func(i *Info, v string) { i.StateReason = v }}, + {"creation_complete_at", func(i *Info, v string) { i.CreationCompleteAt = v }}, + {"continuation_reset_pending", func(i *Info, v string) { i.ContinuationResetPending = v }}, + {ResetCommittedAtKey, func(i *Info, v string) { i.ResetCommittedAt = v }}, + {"generation", func(i *Info, v string) { i.Generation = v }}, + {"started_config_hash", func(i *Info, v string) { i.StartedConfigHash = v }}, + {"pin_awake", func(i *Info, v string) { i.PinAwake = v }}, + + // reconciler decision-read cluster (front-door Phase 5) + {"held_until", func(i *Info, v string) { i.HeldUntil = v }}, + {"wait_hold", func(i *Info, v string) { i.WaitHold = v }}, + {"churn_count", func(i *Info, v string) { i.ChurnCount = v }}, + {"wake_mode", func(i *Info, v string) { i.WakeMode = v }}, + {"sleep_intent", func(i *Info, v string) { i.SleepIntent = v }}, + {"instance_token", func(i *Info, v string) { i.InstanceToken = v }}, + {"detached_at", func(i *Info, v string) { i.DetachedAt = v }}, + {CurrentBeadIDKey, func(i *Info, v string) { i.CurrentlyProcessingBeadID = v }}, + {"core_hash_breakdown", func(i *Info, v string) { i.CoreHashBreakdown = v }}, + {"started_provision_hash", func(i *Info, v string) { i.StartedProvisionHash = v }}, + {"started_launch_hash", func(i *Info, v string) { i.StartedLaunchHash = v }}, + {"started_live_hash", func(i *Info, v string) { i.StartedLiveHash = v }}, + {"config_drift_deferred_at", func(i *Info, v string) { i.ConfigDriftDeferredAt = v }}, + {"config_drift_deferred_key", func(i *Info, v string) { i.ConfigDriftDeferredKey = v }}, + {"attached_config_drift_deferred_at", func(i *Info, v string) { i.AttachedConfigDriftDeferredAt = v }}, + {"attached_config_drift_deferred_key", func(i *Info, v string) { i.AttachedConfigDriftDeferredKey = v }}, + {"stranded_event_emitted_at", func(i *Info, v string) { i.StrandedEventEmittedAt = v }}, + {"session_name_explicit", func(i *Info, v string) { i.SessionNameExplicit = v }}, + {"wake_request", func(i *Info, v string) { i.WakeRequest = v }}, + {"restart_requested", func(i *Info, v string) { i.RestartRequested = v }}, + {"session_id_flag", func(i *Info, v string) { i.SessionIDFlag = v }}, + {"template_overrides", func(i *Info, v string) { i.TemplateOverrides = v }}, + {"provider_kind", func(i *Info, v string) { i.ProviderKind = v }}, + + // wake_attempts: int + raw mirror. The total form (explicit = 0 on parse + // failure) matches ApplyPatch and, on a fresh Info, agrees with the old + // projection's no-set-on-failure. Atoi accepts leading +/- but not + // whitespace — no trimming, to stay byte-identical. + {"wake_attempts", func(i *Info, v string) { + i.WakeAttemptsMetadata = v + if n, err := strconv.Atoi(v); err == nil { + i.WakeAttempts = n + } else { + i.WakeAttempts = 0 + } + }}, + + // last_nudge_delivered_at: RFC3339 time. Reset-to-zero first (clears a + // carried-forward value in the patch direction; a no-op on a fresh Info). + {MetadataLastNudgeDeliveredAt, func(i *Info, v string) { + i.LastNudgeDeliveredAt = time.Time{} + if raw := strings.TrimSpace(v); raw != "" { + if parsed, err := time.Parse(time.RFC3339, raw); err == nil { + i.LastNudgeDeliveredAt = parsed + } + } + }}, +} + +// infoKeyIndex maps each metadata key to its codec spec for O(1) ApplyPatch +// lookup. Built once in init() and never mutated afterward, so concurrent +// reads by reconciler goroutines are race-free by construction. +var infoKeyIndex = func() map[string]*infoKeySpec { + idx := make(map[string]*infoKeySpec, len(infoKeyCodec)) + for i := range infoKeyCodec { + spec := &infoKeyCodec[i] + idx[spec.key] = spec + } + return idx +}() diff --git a/internal/session/info_codec_test.go b/internal/session/info_codec_test.go new file mode 100644 index 0000000000..5a255c2400 --- /dev/null +++ b/internal/session/info_codec_test.go @@ -0,0 +1,357 @@ +package session + +import ( + "reflect" + "strconv" + "strings" + "testing" + "time" + + "github.com/gastownhall/gascity/internal/beadmeta" + "github.com/gastownhall/gascity/internal/beads" +) + +// infoFromPersistedBeadFrozen is a verbatim copy of the pre-S09b struct-literal +// projection of InfoFromPersistedBead. It is the INDEPENDENT oracle for the +// table-driven codec: TestInfoCodecProjectionParity asserts the new table loop +// reproduces this frozen reference byte-for-byte. It must NOT be refactored to +// call the table — its whole value is being written a different way. If a +// genuine projection change is ever intended, this frozen copy fails loudly and +// forces an explicit decision. +func infoFromPersistedBeadFrozen(b beads.Bead) Info { + sessName := b.Metadata["session_name"] + if sessName == "" { + sessName = sessionNameFor(b.ID) + } + closed := b.Status == "closed" + + state := normalizeInfoState(State(b.Metadata["state"])) + if closed { + state = "" + } + + info := Info{ + ID: b.ID, + Type: b.Type, + Template: b.Metadata["template"], + State: state, + Closed: closed, + Title: b.Title, + Alias: b.Metadata["alias"], + AgentName: b.Metadata["agent_name"], + Provider: b.Metadata["provider"], + Transport: transportFromMetadata(b), + Command: b.Metadata["command"], + WorkDir: b.Metadata["work_dir"], + SessionName: sessName, + SessionKey: b.Metadata["session_key"], + ResumeFlag: b.Metadata["resume_flag"], + ResumeStyle: b.Metadata["resume_style"], + ResumeCommand: b.Metadata["resume_command"], + CreatedAt: b.CreatedAt, + + ContinuationEpoch: b.Metadata["continuation_epoch"], + SleepReason: b.Metadata["sleep_reason"], + + ConfiguredNamedIdentity: b.Metadata[NamedSessionIdentityMetadata], + ConfiguredNamedSession: strings.TrimSpace(b.Metadata[NamedSessionMetadataKey]) == "true", + ConfiguredNamedMode: b.Metadata[NamedSessionModeMetadata], + CommonName: b.Metadata["common_name"], + PoolSlot: b.Metadata["pool_slot"], + PoolManaged: strings.TrimSpace(b.Metadata["pool_managed"]) == "true", + SessionOrigin: b.Metadata["session_origin"], + DependencyOnly: strings.TrimSpace(b.Metadata["dependency_only"]) == "true", + DependencyOnlyMetadata: b.Metadata["dependency_only"], + ManualSession: strings.TrimSpace(b.Metadata["manual_session"]) == "true", + ManualSessionMetadata: b.Metadata["manual_session"], + Labels: b.Labels, + MCPIdentity: b.Metadata[MCPIdentityMetadataKey], + MCPServersSnapshot: b.Metadata[MCPServersSnapshotMetadataKey], + + ProviderTerminalError: b.Metadata["provider_terminal_error"], + HealthState: b.Metadata["session_health"], + HealthReason: b.Metadata["session_health_reason"], + Drainable: strings.TrimSpace(b.Metadata["session_drainable"]) == "true", + + TriggerBeadID: b.Metadata[beadmeta.TriggerBeadIDMetadataKey], + TriggerBeadStoreRef: b.Metadata[beadmeta.TriggerBeadStoreRefMetadataKey], + BrainParentSID: b.Metadata[beadmeta.BrainParentSIDMetadataKey], + Pack: b.Metadata[beadmeta.PackMetadataKey], + + MetadataState: b.Metadata["state"], + SessionNameMetadata: b.Metadata["session_name"], + PendingCreateClaim: strings.TrimSpace(b.Metadata["pending_create_claim"]) == "true", + PendingCreateClaimMetadata: b.Metadata["pending_create_claim"], + PendingCreateStartedAt: b.Metadata["pending_create_started_at"], + QuarantinedUntil: b.Metadata["quarantined_until"], + AliasHistory: AliasHistory(b.Metadata), + ContinuityEligible: b.Metadata["continuity_eligible"], + TransportMetadata: b.Metadata["transport"], + LastWokeAt: b.Metadata["last_woke_at"], + StateReason: b.Metadata["state_reason"], + CreationCompleteAt: b.Metadata["creation_complete_at"], + ContinuationResetPending: b.Metadata["continuation_reset_pending"], + ResetCommittedAt: b.Metadata[ResetCommittedAtKey], + Generation: b.Metadata["generation"], + StartedConfigHash: b.Metadata["started_config_hash"], + PinAwake: b.Metadata["pin_awake"], + + HeldUntil: b.Metadata["held_until"], + WaitHold: b.Metadata["wait_hold"], + ChurnCount: b.Metadata["churn_count"], + WakeMode: b.Metadata["wake_mode"], + SleepIntent: b.Metadata["sleep_intent"], + InstanceToken: b.Metadata["instance_token"], + DetachedAt: b.Metadata["detached_at"], + CurrentlyProcessingBeadID: b.Metadata[CurrentBeadIDKey], + CoreHashBreakdown: b.Metadata["core_hash_breakdown"], + StartedProvisionHash: b.Metadata["started_provision_hash"], + StartedLaunchHash: b.Metadata["started_launch_hash"], + StartedLiveHash: b.Metadata["started_live_hash"], + ConfigDriftDeferredAt: b.Metadata["config_drift_deferred_at"], + ConfigDriftDeferredKey: b.Metadata["config_drift_deferred_key"], + AttachedConfigDriftDeferredAt: b.Metadata["attached_config_drift_deferred_at"], + AttachedConfigDriftDeferredKey: b.Metadata["attached_config_drift_deferred_key"], + StrandedEventEmittedAt: b.Metadata["stranded_event_emitted_at"], + SessionNameExplicit: b.Metadata["session_name_explicit"], + WakeRequest: b.Metadata["wake_request"], + RestartRequested: b.Metadata["restart_requested"], + SessionIDFlag: b.Metadata["session_id_flag"], + TemplateOverrides: b.Metadata["template_overrides"], + WakeAttemptsMetadata: b.Metadata["wake_attempts"], + ProviderKind: b.Metadata["provider_kind"], + } + if n, err := strconv.Atoi(b.Metadata["wake_attempts"]); err == nil { + info.WakeAttempts = n + } + if raw := strings.TrimSpace(b.Metadata[MetadataLastNudgeDeliveredAt]); raw != "" { + if parsed, err := time.Parse(time.RFC3339, raw); err == nil { + info.LastNudgeDeliveredAt = parsed + } + } + return info +} + +// TestInfoCodecProjectionParity (T2) is the independent projection oracle: the +// new table-driven InfoFromPersistedBead must equal the frozen pre-S09b +// struct-literal projection byte-for-byte, over the diverse oracle base beads +// (populated/closed/no-name/acp/sparse) plus per-key edge fixtures that reach +// the parsed/coupled branches (Atoi failure, RFC3339 garbage, alias +// normalization, whitespace bools, awake/drained state remap). Unlike the +// fold==reprojection oracle (which compares two table-driven paths), this pins +// the table against a copy of the OLD code, so a shared table bug is caught. +func TestInfoCodecProjectionParity(t *testing.T) { + beadsToCheck := oracleBaseBeads() + + created := time.Date(2026, 2, 3, 4, 5, 6, 0, time.UTC) + edgeMeta := []map[string]string{ + {"wake_attempts": "not-an-int"}, + {"wake_attempts": "12"}, + {"wake_attempts": " 3 "}, // Atoi rejects whitespace -> 0 + {MetadataLastNudgeDeliveredAt: "garbage"}, + {MetadataLastNudgeDeliveredAt: " 2025-06-01T00:00:00Z "}, + {aliasHistoryMetadataKey: " a , b ,a, c "}, + {aliasHistoryMetadataKey: ""}, + {"pool_managed": " true ", "dependency_only": " true ", "manual_session": "TRUE"}, + {"state": "awake"}, + {"state": "drained"}, + {"provider": "acp"}, // provider fallback -> transport "acp" + {"provider": "acp", "transport": ""}, // explicit empty transport, provider fallback + {"provider": "claude", "transport": ""}, // no fallback -> transport "" + {"session_name": ""}, // sessionNameFor fallback + } + for i, m := range edgeMeta { + beadsToCheck = append(beadsToCheck, + beads.Bead{ID: "edge-" + strconv.Itoa(i), Type: "gc:session", Status: "open", Title: "E", Labels: []string{"gc:session"}, CreatedAt: created, Metadata: m}, + beads.Bead{ID: "edge-closed-" + strconv.Itoa(i), Type: "gc:session", Status: "closed", Title: "E", Labels: []string{"gc:session"}, CreatedAt: created, Metadata: m}, + ) + } + + for _, b := range beadsToCheck { + got := InfoFromPersistedBead(b) + want := infoFromPersistedBeadFrozen(b) + if !reflect.DeepEqual(got, want) { + t.Errorf("bead=%s: table projection diverged from frozen reference\n got=%+v\nwant=%+v", b.ID, got, want) + } + } +} + +// TestInfoCodecKeysMatchProjectedList (T1) asserts the table's key set equals +// the hand-maintained allProjectedMetadataKeys list used by the fold oracle. +// A silently dropped or extra table entry is caught here even if the fold +// oracle's key list drifts in lockstep. +func TestInfoCodecKeysMatchProjectedList(t *testing.T) { + tableKeys := map[string]bool{} + for i := range infoKeyCodec { + k := infoKeyCodec[i].key + if tableKeys[k] { + t.Errorf("duplicate key %q in infoKeyCodec", k) + } + tableKeys[k] = true + } + listKeys := map[string]bool{} + for _, k := range allProjectedMetadataKeys { + listKeys[k] = true + } + for k := range tableKeys { + if !listKeys[k] { + t.Errorf("infoKeyCodec key %q missing from allProjectedMetadataKeys", k) + } + } + for k := range listKeys { + if !tableKeys[k] { + t.Errorf("allProjectedMetadataKeys key %q missing from infoKeyCodec", k) + } + } + if len(infoKeyIndex) != len(infoKeyCodec) { + t.Errorf("infoKeyIndex size %d != infoKeyCodec size %d (duplicate key collapsed?)", len(infoKeyIndex), len(infoKeyCodec)) + } +} + +// TestInfoCodecEmptyStringClears (T3) drives the empty-string-clear invariant +// (I3) off the table: for every key, folding {key: ""} onto a fully-populated +// projection must equal projecting the same bead with that key deleted. +func TestInfoCodecEmptyStringClears(t *testing.T) { + base := oracleBaseBeads()[0] // fully-populated open bead + baseInfo := InfoFromPersistedBead(base) + for i := range infoKeyCodec { + key := infoKeyCodec[i].key + cleared := baseInfo.ApplyPatch(MetadataPatch{key: ""}) + + deletedMeta := make(map[string]string, len(base.Metadata)) + for k, v := range base.Metadata { + if k == key { + continue + } + deletedMeta[k] = v + } + deleted := base + deleted.Metadata = deletedMeta + want := InfoFromPersistedBead(deleted) + + if !reflect.DeepEqual(cleared, want) { + t.Errorf("key=%q: empty-string clear diverged from key-deleted projection\n got=%+v\nwant=%+v", key, cleared, want) + } + } +} + +// TestInfoCodecFieldsDisjoint (T4) locks invariant I6: every pair of table +// setters writes a disjoint set of Info fields, EXCEPT the documented +// provider/transport pair (both derive Transport). It also asserts +// provider precedes transport in the table so the derived Transport is +// finalized with both raw mirrors in scope. +func TestInfoCodecFieldsDisjoint(t *testing.T) { + // touchedFields applies a setter with a sentinel value to a zero Info and + // returns the set of struct field indices it changed. + touchedFields := func(set func(*Info, string), v string) map[int]bool { + var info Info + set(&info, v) + changed := map[int]bool{} + zero := Info{} + rv, rz := reflect.ValueOf(info), reflect.ValueOf(zero) + for f := 0; f < rv.NumField(); f++ { + if !reflect.DeepEqual(rv.Field(f).Interface(), rz.Field(f).Interface()) { + changed[f] = true + } + } + return changed + } + + // sentinelFor returns a per-key value that actually moves every field the + // key's setter writes off its zero value, so each setter contributes a + // non-empty touched-field set to the pairwise check. The default "1" trims + // to a truthy int and a non-empty string, but the `== "true"` boolean + // setters only flip their bool on "true", and the RFC3339-only + // last_nudge_delivered_at setter only moves on a valid timestamp; without + // these overrides those setters report an empty set and silently drop out of + // the disjointness assertion — the exact invariant this test exists to prove. + sentinelFor := func(key string) string { + switch key { + case NamedSessionMetadataKey, "pool_managed", "dependency_only", + "manual_session", "session_drainable", "pending_create_claim": + return "true" + case MetadataLastNudgeDeliveredAt: + return "2025-06-01T00:00:00Z" + default: + return "1" + } + } + + fields := make([]map[int]bool, len(infoKeyCodec)) + for i := range infoKeyCodec { + fields[i] = touchedFields(infoKeyCodec[i].set, sentinelFor(infoKeyCodec[i].key)) + } + + providerIdx, transportIdx := -1, -1 + for i := range infoKeyCodec { + switch infoKeyCodec[i].key { + case "provider": + providerIdx = i + case "transport": + transportIdx = i + } + } + if providerIdx == -1 || transportIdx == -1 { + t.Fatal("provider/transport keys not found in table") + } + if providerIdx >= transportIdx { + t.Errorf("provider (idx %d) must precede transport (idx %d) in infoKeyCodec", providerIdx, transportIdx) + } + + isProviderTransportPair := func(a, b int) bool { + return (a == providerIdx && b == transportIdx) || (a == transportIdx && b == providerIdx) + } + + for a := range infoKeyCodec { + for b := a + 1; b < len(infoKeyCodec); b++ { + if isProviderTransportPair(a, b) { + continue + } + for f := range fields[a] { + if fields[b][f] { + t.Errorf("keys %q and %q both write Info field index %d (non-disjoint)", infoKeyCodec[a].key, infoKeyCodec[b].key, f) + } + } + } + } +} + +// TestInfoCodecProviderTransportOrderConverges (part of R2 mitigation) applies +// a two-key provider+transport patch in BOTH iteration orders and asserts the +// final Transport matches the from-scratch projection either way. Guards the +// E-5 convergence property against a future reorder. +func TestInfoCodecProviderTransportOrderConverges(t *testing.T) { + base := oracleBaseBeads()[3] // acp base: provider fallback is live + baseInfo := InfoFromPersistedBead(base) + + quadrants := []struct{ provider, transport string }{ + {"gemini", "tmux"}, + {"acp", ""}, + {"claude", ""}, + {"", "acp"}, + {"", ""}, + } + for _, q := range quadrants { + // Apply as separate single-key patches in each order (single-key + // ApplyPatch calls make the ordering explicit and deterministic). + fwd := baseInfo.ApplyPatch(MetadataPatch{"provider": q.provider}).ApplyPatch(MetadataPatch{"transport": q.transport}) + rev := baseInfo.ApplyPatch(MetadataPatch{"transport": q.transport}).ApplyPatch(MetadataPatch{"provider": q.provider}) + + wantMeta := make(map[string]string, len(base.Metadata)) + for k, v := range base.Metadata { + wantMeta[k] = v + } + wantMeta["provider"] = q.provider + wantMeta["transport"] = q.transport + wantBead := base + wantBead.Metadata = wantMeta + want := InfoFromPersistedBead(wantBead) + + if fwd.Transport != want.Transport { + t.Errorf("provider=%q transport=%q: fwd Transport=%q, want %q", q.provider, q.transport, fwd.Transport, want.Transport) + } + if rev.Transport != want.Transport { + t.Errorf("provider=%q transport=%q: rev Transport=%q, want %q", q.provider, q.transport, rev.Transport, want.Transport) + } + } +} diff --git a/internal/session/info_store.go b/internal/session/info_store.go index 20d4638618..b1bd21833a 100644 --- a/internal/session/info_store.go +++ b/internal/session/info_store.go @@ -2,11 +2,8 @@ package session import ( "fmt" - "strconv" "strings" - "time" - "github.com/gastownhall/gascity/internal/beadmeta" "github.com/gastownhall/gascity/internal/beads" ) @@ -21,129 +18,26 @@ import ( // Info. Callers that need live runtime state (Attached, runtime-downgraded // State, detected transport) must go through Manager, not this function. func InfoFromPersistedBead(b beads.Bead) Info { - sessName := b.Metadata["session_name"] - if sessName == "" { - sessName = sessionNameFor(b.ID) - } - closed := b.Status == "closed" - - state := normalizeInfoState(State(b.Metadata["state"])) - if closed { - state = "" // closed beads have no runtime state - } - + // Bead-level prologue: fields that are not metadata-derived. These MUST be + // set before the codec table runs — the session_name setter reads info.ID + // for its sessionNameFor fallback, and the state setter reads info.Closed to + // blank State on closed beads (invariant I6). info := Info{ - ID: b.ID, - Type: b.Type, - Template: b.Metadata["template"], - State: state, - Closed: closed, - Title: b.Title, - Alias: b.Metadata["alias"], - AgentName: b.Metadata["agent_name"], - Provider: b.Metadata["provider"], - Transport: transportFromMetadata(b), - Command: b.Metadata["command"], - WorkDir: b.Metadata["work_dir"], - SessionName: sessName, - SessionKey: b.Metadata["session_key"], - ResumeFlag: b.Metadata["resume_flag"], - ResumeStyle: b.Metadata["resume_style"], - ResumeCommand: b.Metadata["resume_command"], - CreatedAt: b.CreatedAt, - - ContinuationEpoch: b.Metadata["continuation_epoch"], - SleepReason: b.Metadata["sleep_reason"], - - // identity / pool / named-session cluster - ConfiguredNamedIdentity: b.Metadata[NamedSessionIdentityMetadata], - ConfiguredNamedSession: strings.TrimSpace(b.Metadata[NamedSessionMetadataKey]) == "true", - ConfiguredNamedMode: b.Metadata[NamedSessionModeMetadata], - CommonName: b.Metadata["common_name"], - PoolSlot: b.Metadata["pool_slot"], - PoolManaged: strings.TrimSpace(b.Metadata["pool_managed"]) == "true", - SessionOrigin: b.Metadata["session_origin"], - DependencyOnly: strings.TrimSpace(b.Metadata["dependency_only"]) == "true", - DependencyOnlyMetadata: b.Metadata["dependency_only"], - ManualSession: strings.TrimSpace(b.Metadata["manual_session"]) == "true", - ManualSessionMetadata: b.Metadata["manual_session"], - Labels: b.Labels, - MCPIdentity: b.Metadata[MCPIdentityMetadataKey], - MCPServersSnapshot: b.Metadata[MCPServersSnapshotMetadataKey], - - // health / provider-terminal-error cluster. The key literals mirror the - // cmd/gc session_reconcile constants (session_health, session_drainable, - // …); the classifier-equivalence test guards against drift. - ProviderTerminalError: b.Metadata["provider_terminal_error"], - HealthState: b.Metadata["session_health"], - HealthReason: b.Metadata["session_health_reason"], - Drainable: strings.TrimSpace(b.Metadata["session_drainable"]) == "true", - - // trigger / brain-parent cluster (canonical gc.* keys via beadmeta). - TriggerBeadID: b.Metadata[beadmeta.TriggerBeadIDMetadataKey], - TriggerBeadStoreRef: b.Metadata[beadmeta.TriggerBeadStoreRefMetadataKey], - BrainParentSID: b.Metadata[beadmeta.BrainParentSIDMetadataKey], - Pack: b.Metadata[beadmeta.PackMetadataKey], - - // state / bookkeeping cluster. MetadataState is the RAW state metadata, - // kept verbatim so the reconciler classifiers read the same value the - // bead carried (Info.State above is the normalized, closed-blanked form). - MetadataState: b.Metadata["state"], - SessionNameMetadata: b.Metadata["session_name"], - PendingCreateClaim: strings.TrimSpace(b.Metadata["pending_create_claim"]) == "true", - PendingCreateClaimMetadata: b.Metadata["pending_create_claim"], - PendingCreateStartedAt: b.Metadata["pending_create_started_at"], - QuarantinedUntil: b.Metadata["quarantined_until"], - AliasHistory: AliasHistory(b.Metadata), - ContinuityEligible: b.Metadata["continuity_eligible"], - TransportMetadata: b.Metadata["transport"], - LastWokeAt: b.Metadata["last_woke_at"], - StateReason: b.Metadata["state_reason"], - CreationCompleteAt: b.Metadata["creation_complete_at"], - ContinuationResetPending: b.Metadata["continuation_reset_pending"], - ResetCommittedAt: b.Metadata[ResetCommittedAtKey], - Generation: b.Metadata["generation"], - StartedConfigHash: b.Metadata["started_config_hash"], - PinAwake: b.Metadata["pin_awake"], - - // reconciler decision-read cluster (front-door Phase 5). Raw mirrors of - // the keys the reconciler decision paths still crack inline. The key - // literals mirror the cmd/gc reconciler constants (config_drift_deferred_*, - // attached_config_drift_deferred_*, stranded_event_emitted_at, …); the - // classifier-equivalence oracle feeds those constants and so guards these - // literals against drift. CurrentBeadIDKey is a session-package constant. - HeldUntil: b.Metadata["held_until"], - WaitHold: b.Metadata["wait_hold"], - ChurnCount: b.Metadata["churn_count"], - WakeMode: b.Metadata["wake_mode"], - SleepIntent: b.Metadata["sleep_intent"], - InstanceToken: b.Metadata["instance_token"], - DetachedAt: b.Metadata["detached_at"], - CurrentlyProcessingBeadID: b.Metadata[CurrentBeadIDKey], - CoreHashBreakdown: b.Metadata["core_hash_breakdown"], - StartedProvisionHash: b.Metadata["started_provision_hash"], - StartedLaunchHash: b.Metadata["started_launch_hash"], - StartedLiveHash: b.Metadata["started_live_hash"], - ConfigDriftDeferredAt: b.Metadata["config_drift_deferred_at"], - ConfigDriftDeferredKey: b.Metadata["config_drift_deferred_key"], - AttachedConfigDriftDeferredAt: b.Metadata["attached_config_drift_deferred_at"], - AttachedConfigDriftDeferredKey: b.Metadata["attached_config_drift_deferred_key"], - StrandedEventEmittedAt: b.Metadata["stranded_event_emitted_at"], - SessionNameExplicit: b.Metadata["session_name_explicit"], - WakeRequest: b.Metadata["wake_request"], - RestartRequested: b.Metadata["restart_requested"], - SessionIDFlag: b.Metadata["session_id_flag"], - TemplateOverrides: b.Metadata["template_overrides"], - WakeAttemptsMetadata: b.Metadata["wake_attempts"], - ProviderKind: b.Metadata["provider_kind"], - } - if n, err := strconv.Atoi(b.Metadata["wake_attempts"]); err == nil { - info.WakeAttempts = n + ID: b.ID, + Type: b.Type, + Title: b.Title, + Labels: b.Labels, + CreatedAt: b.CreatedAt, + Closed: b.Status == "closed", } - if raw := strings.TrimSpace(b.Metadata[MetadataLastNudgeDeliveredAt]); raw != "" { - if parsed, err := time.Parse(time.RFC3339, raw); err == nil { - info.LastNudgeDeliveredAt = parsed - } + // Project every metadata-derived field through the shared codec table. An + // absent key reads as "" (Go map default), matching the old struct literal's + // zero-valued reads; each setter is total over "". Starting from a fresh + // zero-valued Info, the table's ApplyPatch-form setters reproduce the old + // projection exactly (invariant I1, gated by the parity oracle tests). + for i := range infoKeyCodec { + spec := &infoKeyCodec[i] + spec.set(&info, b.Metadata[spec.key]) } return info } From 32dc11efd274dd588b3488b9136e71c3e4f24635 Mon Sep 17 00:00:00 2001 From: Stephanie Jarmak <36544495+sjarmak@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:29:56 -0400 Subject: [PATCH 003/225] feat(runtime): treat unreachable tmux as partial observation, not "no sessions" (#4082) ## Why An unreachable tmux server is an observation failure, not the fact "no sessions exist." Today `tmuxFetcher.FetchState` converts `ErrNoServer` into an empty success, so `StateCache.refresh()` overwrites its last-known-good and every session instantly reads not-running. A brief server blip (a supervisor restart, a transient socket stall) then drives the reconciler to drain and close healthy pool slots. This is the source-level fix for that class, mirroring the existing `storeQueryPartial` discipline on the store side. It is one of three independent pieces split out of a polecat-reliability audit; the other two (a warm-worker wake and an idempotent-sling `--nudge` fix) are separate PRs. ## What changed - `internal/runtime/tmux/state_cache.go`: `FetchState` returns `runtime.ErrRuntimeUnavailable` (wrapping the cause) on an unreachable server instead of an empty success, so `refresh()` preserves last-known-good until the existing `staleTTL` cliff. Genuine session ends still evict immediately via `Stop()`/`EvictSession`, so they are not masked. The wrapped error still satisfies `isNoServerError`, so the existing `ErrNoServer` absorbers are unaffected. - `internal/runtime/runtime.go`: adds the `ErrRuntimeUnavailable` sentinel with a doc comment distinguishing it from the existing `PartialListError` (single-observation total failure vs. multi-backend partial-but-usable). - `engdocs/design/runtime-partial-discipline.md`: records the design, and names the reconciler-facing `ListRunning` sites (`city_runtime.go` on_death, provider-swap, shutdown) that this fix does not yet cover, with the follow-up path: emit the existing `PartialListError` from `ListRunning`/`ListSessions` on `ErrNoServer` to activate the four `IsPartialListError` guards those sites already have. ## Bounded behavior change An externally-killed last session is now reported running from last-known-good for up to `staleTTL` (~30s) before the cliff clears it. This is the intended trade: a bounded cleanup delay instead of draining every slot on a blip. ## Test plan - `go build ./...`, `go vet ./internal/runtime/...`: clean - `go test ./internal/runtime/... ./internal/runtime/tmux/`: pass, including new `TestTmuxFetcher_NoServerMapsToRuntimeUnavailable` and `TestStateCache_NoServerRefreshPreservesLastKnownGood` --------- Co-authored-by: sjarmak --- engdocs/design/index.md | 1 + engdocs/design/runtime-partial-discipline.md | 100 +++++++++++++++++++ internal/runtime/runtime.go | 18 ++++ internal/runtime/tmux/state_cache.go | 18 +++- internal/runtime/tmux/state_cache_test.go | 51 ++++++++++ 5 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 engdocs/design/runtime-partial-discipline.md diff --git a/engdocs/design/index.md b/engdocs/design/index.md index 2c8cc0ca86..06cadbce3c 100644 --- a/engdocs/design/index.md +++ b/engdocs/design/index.md @@ -23,6 +23,7 @@ lives in the [Architecture](../architecture/index.md) section. | `dependency-aware-bounded-parallel-lifecycle` | Implemented | Bounded parallel start/stop waves for session lifecycle | | `beads-dolt-contract-redesign` | Accepted | Canonical bd+Dolt contract, topology commands, migration, and provider-boundary redesign | | `idle-session-sleep` | Accepted | Idle-sleep policy, precedence, and wake mechanics | +| `runtime-partial-discipline` | Accepted (source-level), follow-ups Proposed | Treat a failed tmux-liveness observation as partial (defer destructive arms) instead of "nothing running"; mirrors storeQueryPartial | | `idle-controller-call-rate` | Proposed | Layer-3 (#2463/#3543) cut of the controller's idle bd/Dolt call *rate*: demand-gated ticking, per-pass snapshot, quiescent-scope skipping | | `session-store-fences` | Accepted | Cross-process write fences for session-owned metadata: store facts, flock and token-reread fences, residual convergence-through-persistence | | `named-configured-sessions` | Accepted | Explicit canonical named sessions backed by reusable templates; partially superseded by `session-model-unification` | diff --git a/engdocs/design/runtime-partial-discipline.md b/engdocs/design/runtime-partial-discipline.md new file mode 100644 index 0000000000..9e9ca678fc --- /dev/null +++ b/engdocs/design/runtime-partial-discipline.md @@ -0,0 +1,100 @@ +--- +title: Runtime-partial discipline +description: Treating a failed runtime-liveness observation as "I could not tell" instead of "nothing is running", mirroring the store-partial guard. +--- + +The bead-store side already distinguishes a partial/failed read from a real +"no rows" answer: `storeQueryPartial` threads through the session reconciler and +gates every destructive arm (close-as-orphaned, drain-ack stop, pending-create +rollback) so a degraded store never causes a healthy session to be torn down +(`cmd/gc/session_reconciler.go`, search `storeQueryPartial`). + +The runtime side had no equivalent. A tmux-liveness observation that FAILED +(server briefly unreachable) was indistinguishable from the fact "no sessions +exist", so a brief blip drove the reconciler to drain/close healthy pool slots. + +## Landed (this PR) + +- `runtime.ErrRuntimeUnavailable` sentinel in `internal/runtime/runtime.go` — + the runtime-side analogue of a partial store read. Callers dispatch on it with + `errors.Is`. +- `internal/runtime/tmux/state_cache.go` `tmuxFetcher.FetchState`: an + unreachable server (`ErrNoServer`) now returns `ErrRuntimeUnavailable` + (wrapping the original cause) instead of an empty *success*. `refresh()` + therefore preserves the cache's last-known-good until the existing `staleTTL` + cliff, so a brief outage no longer collapses `IsRunning` to false. This is the + highest-leverage single point on the **liveness** path: the reconciler's + `IsRunning` / `ObserveLiveness` reads all flow through `StateCache`, so + protecting the observation source shields that whole path at once, bounded by + `staleTTL` (30s default). The wrapped error still satisfies `isNoServerError`, + so the ~20 existing `ErrNoServer` absorbers are unaffected. + +### Still exposed: the `ListRunning` sites + +The `FetchState` fix shields the `StateCache.IsRunning` liveness path, but NOT +`Provider.ListRunning` (via `Tmux.ListSessions`), which still returns +`(nil, nil)` on `ErrNoServer` — an empty *success*, not a partial signal. Three +reconciler-facing sites call `ListRunning` destructively on that empty result: + +- `cmd/gc/city_runtime.go:960` — pool `on_death` hooks. On a full tmux outage + every pool slot vanishes from the empty listing at once, so the tick fires the + user's `on_death` command for EVERY pool slot: a false death storm. +- `cmd/gc/city_runtime.go:1899` — provider swap on config reload. +- `cmd/gc/city_runtime.go:3466` / `:3478` — shutdown (and the force-shutdown + late-async-start re-list) session listing. + +All four `IsPartialListError` guards at those call sites already exist (verified +in-tree), but none fires today because `ListRunning` returns a nil error on +`ErrNoServer`. The clean completion path is doc-only from here: emit the +EXISTING `PartialListError` from `Provider.ListRunning` / `Tmux.ListSessions` on +`ErrNoServer` (arm 6 below), which activates all four guards with no new +plumbing — the `on_death` storm is arm 4. + +### Bounded behavior change (maintainer, please confirm) + +Genuine session ends evict from the cache immediately via `Stop()` / +`EvictSession`, so they are NOT masked. The one residual: an **externally** +killed **last** session (killed outside `Stop`, which also makes tmux exit-empty +and return `ErrNoServer`) is reported running from last-known-good for up to +`staleTTL` before the cliff clears it. This is the intended trade — a bounded +cleanup delay in an edge case, versus draining every pool slot on a blip — but +it is a real behavior change and is called out here for explicit sign-off. + +## Follow-up arms (not yet threaded) + +Even with the source-level fix, each of these destructive arms should read a +`runtimeQueryPartial` signal and defer, mirroring the `storeQueryPartial` +branches, for the window AFTER `staleTTL` (when the cache legitimately goes +empty but the runtime is still just unreachable). Do them one at a time, each +with a `beadReconcileTick`-level test that asserts the arm defers under a +partial runtime observation: + +1. **state_cache staleTTL cliff** — after `staleTTL`, `currentState()` returns an + empty snapshot (`state_cache.go` ~line 148). Expose a `Degraded()`/partial + status so consumers can distinguish "empty because unreachable" from "empty + because idle", instead of silently reporting all-not-running. +2. **heal-to-asleep slot-free** — `cmd/gc/session_reconciler.go` heal path + (`healStateWithRollback`, ~line 1692): a `!providerAlive` observation drives a + running session toward asleep/closed. Gate with `!runtimeQueryPartial`. +3. **orphan close / drain-advance false-complete** — the `!desired` orphan branch + (`session_reconciler.go` ~1537) and drain completion: an empty/negative + observation must not advance a drain to "complete" or close a pool bead as + orphaned when the runtime query was partial. +4. **on_death storm** — the death handler that fires when a session is observed + gone: suppress the death cascade when the observation was runtime-partial. +5. **pre-start orphan fail-open** — `cmd/gc/session_wake.go` (~line 552, + `if err != nil { running = false }`): a failed reachability probe currently + falls open to "not running"; it should treat `ErrRuntimeUnavailable` as + partial and defer. +6. **`Tmux.ListSessions` / `Tmux.HasSession`** (`internal/runtime/tmux/tmux.go` + ~993-1018): these still return `nil,nil` / `false,nil` on `ErrNoServer` for + their (tmux-internal) callers. They are not on the reconciler liveness path + (that path is `list-panes` via `FetchState`), so they were left alone here; + surface `ErrRuntimeUnavailable` from them too for consistency once a consumer + needs it, auditing each internal caller to preserve today's absorb behavior. + +The plumbing to get a per-tick `runtimeQueryPartial` to the reconciler arms +(optional provider interface via type-assert, like `LivenessObserver`, plus a +`Liveness.RuntimePartial` field) is the shared prerequisite for 1-5; it is the +load-bearing design step and should be reviewed on its own before the arms are +converted. diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 808aad8f48..8b1e508efd 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -48,6 +48,24 @@ var ErrSessionNotFound = errors.New("session not found") // exit 2). Carriers treat this as "fall back to the legacy driving op". var ErrExecUnsupported = errors.New("runtime does not implement the exec op") +// ErrRuntimeUnavailable reports that a runtime-liveness query could not observe +// the underlying runtime at all — the tmux server was unreachable, the process +// table could not be scanned, etc. It is the runtime-side analog of a partial +// bead-store read: an observation FAILURE, not the fact "no sessions exist". A +// destructive reconciler arm (close-as-orphaned, heal-to-asleep, sweep) must +// treat it as "I could not tell" and defer, exactly as it defers on a partial +// store read (storeQueryPartial) — never as ground truth that every session is +// gone. Providers wrap it (with errors.Is-visible provider-specific causes) so +// callers can dispatch on it with errors.Is. +// +// This is distinct from [PartialListError]. ErrRuntimeUnavailable is the +// single-observation-total-failure signal: zero usable data, used to preserve +// StateCache last-known-good. PartialListError is the multi-backend-merge +// signal: partial-but-usable results from [MergeBackendListResults], which does +// not even emit PartialListError for a total failure. The two are intentionally +// separate signals for separate call paths. +var ErrRuntimeUnavailable = errors.New("runtime unavailable: liveness observation failed") + // ErrRelaunchUnsupported reports that the underlying runtime cannot relaunch the // agent in a warm box (it is not a [RelaunchProvider], or is conjoined like // subprocess/acp/t3bridge). Composite/wrapping providers return it from their diff --git a/internal/runtime/tmux/state_cache.go b/internal/runtime/tmux/state_cache.go index 9f7baf3ac3..94183488d6 100644 --- a/internal/runtime/tmux/state_cache.go +++ b/internal/runtime/tmux/state_cache.go @@ -14,6 +14,7 @@ import ( "sync" "time" + "github.com/gastownhall/gascity/internal/runtime" "golang.org/x/sync/singleflight" ) @@ -231,7 +232,22 @@ func (f *tmuxFetcher) FetchState(ctx context.Context) (runtimeStateSnapshot, err out, err := f.tm.runCtx(ctx, "list-panes", "-a", "-F", "#{session_name}\t#{pane_dead}\t#{pane_current_command}\t#{pane_pid}") if err != nil { if isNoServerError(err) { - return runtimeStateSnapshot{Sessions: map[string]sessionRuntimeState{}}, nil // No server = no sessions + // An unreachable tmux server is an observation FAILURE, not the + // fact "no sessions exist". Returning an empty *success* here let + // refresh() overwrite the cache's last-known-good and instantly + // report every session as not-running, so a brief server blip (a + // supervisor restart, a transient socket stall) drove the + // reconciler to drain/close healthy pool slots. Surface it as + // runtime.ErrRuntimeUnavailable instead: refresh() then preserves + // last-known-good until the existing staleTTL cliff, bounding the + // trust window. Genuine session ends evict from the cache via + // Stop()/EvictSession, so they are not masked by this preservation + // (the only residual is an externally-killed LAST session, whose + // cleanup is delayed by at most staleTTL — the intended trade). + // isNoServerError still matches the wrapped error (it contains the + // original "no server running" cause), so downstream absorbers are + // unaffected. + return runtimeStateSnapshot{}, fmt.Errorf("%w: %w", runtime.ErrRuntimeUnavailable, err) } return runtimeStateSnapshot{}, err } diff --git a/internal/runtime/tmux/state_cache_test.go b/internal/runtime/tmux/state_cache_test.go index 3682dee5dd..4e9ca40427 100644 --- a/internal/runtime/tmux/state_cache_test.go +++ b/internal/runtime/tmux/state_cache_test.go @@ -12,6 +12,8 @@ import ( "sync/atomic" "testing" "time" + + gcruntime "github.com/gastownhall/gascity/internal/runtime" ) // mockFetcher implements StateFetcher for testing. @@ -309,6 +311,55 @@ func TestProviderObserveLivenessUsesCacheProcessSnapshot(t *testing.T) { } } +// FetchState must report an unreachable tmux server as an observation FAILURE +// (runtime.ErrRuntimeUnavailable), not as an empty success. The empty-success +// form let refresh() overwrite last-known-good and instantly report every +// session not-running, draining healthy pool slots on a brief tmux blip. The +// wrapped error must still satisfy isNoServerError so downstream absorbers keep +// working. +func TestTmuxFetcher_NoServerMapsToRuntimeUnavailable(t *testing.T) { + f := &tmuxFetcher{tm: &Tmux{cfg: DefaultConfig(), exec: &fakeExecutor{err: ErrNoServer}}} + + snap, err := f.FetchState(context.Background()) + if err == nil { + t.Fatalf("FetchState() err = nil (snapshot %+v), want an error for an unreachable server", snap) + } + if !errors.Is(err, gcruntime.ErrRuntimeUnavailable) { + t.Fatalf("FetchState() err = %v, want errors.Is(runtime.ErrRuntimeUnavailable)", err) + } + if !isNoServerError(err) { + t.Fatalf("FetchState() err = %v must still satisfy isNoServerError so downstream ErrNoServer absorbers work", err) + } +} + +// End to end at the cache: after a good prime, an ErrNoServer refresh must +// preserve last-known-good (within staleTTL) instead of collapsing to empty. +func TestStateCache_NoServerRefreshPreservesLastKnownGood(t *testing.T) { + fe := &fakeExecutor{ + // FetchState issues exactly one executor call (list-panes); the + // process-table half reads /proc directly, not through exec. First + // call primes one live pane, every later call reports no server. + outs: []string{"agent-1\t0\tclaude\t123"}, + errs: []error{nil, ErrNoServer, ErrNoServer, ErrNoServer}, + } + cache := NewStateCache(&tmuxFetcher{tm: &Tmux{cfg: DefaultConfig(), exec: fe}}, time.Nanosecond) + + if !cache.IsRunning("agent-1") { + t.Fatal("expected agent-1 running after prime") + } + // TTL is a nanosecond, so the next read forces a refresh that hits + // ErrNoServer. Last-known-good must survive it (staleTTL default 30s). + if !cache.IsRunning("agent-1") { + t.Error("expected agent-1 still running after an ErrNoServer refresh (last-known-good); a brief tmux outage must not report sessions as gone") + } + cache.mu.RLock() + lastErr := cache.lastError + cache.mu.RUnlock() + if !errors.Is(lastErr, gcruntime.ErrRuntimeUnavailable) { + t.Fatalf("cache.lastError = %v, want errors.Is(runtime.ErrRuntimeUnavailable)", lastErr) + } +} + func TestStateCache_RefreshFailurePreservesLastKnownGood(t *testing.T) { f := &mockFetcher{ sessions: map[string]bool{"agent-1": true}, From 13e58da113541da717e03eb620170261514acf1e Mon Sep 17 00:00:00 2001 From: Stephanie Jarmak <36544495+sjarmak@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:30:04 -0400 Subject: [PATCH 004/225] fix(sling): honor --nudge on idempotent re-slings (#4084) ## Why Re-slinging a bead with `--nudge` silently drops the nudge. When a bead is already routed to the target, `preflight` short-circuits as idempotent and returns before `result.NudgeAgent` is ever set, and the CLI only delivers a nudge when that field is set. So the natural operator repair for a warm worker that missed its wake ("sling it again, with `--nudge`") is a no-op, and the bead stays unclaimed. This composes with the warm-worker wake gap into the "bead sits unclaimed forever" symptom. Split out of a polecat-reliability audit; the runtime-partial fix and the warm-worker wake are separate PRs. ## What changed - `internal/sling/sling_core.go`: on an idempotent sling result, when `opts.Nudge` is set and it is not a dry run, still set the nudge signal so the CLI delivers the wake. The claim path is CAS-safe, so a redundant nudge is harmless; this makes "re-sling with `--nudge`" a reliable repair verb. ## Coordination note Touches `internal/sling/sling_core.go`, also touched by open PR #3768. The changes are in different regions and merge cleanly (verified via 3-way merge-tree); no manual conflict resolution is needed regardless of merge order. ## Test plan - `go build ./...`: clean - `go test ./internal/sling/`: pass, including new `TestDoSlingIdempotentHonorsNudge` and `TestDoSlingIdempotentDryRunSuppressesNudge` Co-authored-by: sjarmak --- internal/sling/sling_core.go | 11 ++++++ internal/sling/sling_test.go | 71 ++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/internal/sling/sling_core.go b/internal/sling/sling_core.go index 8e441697a6..9402b72a44 100644 --- a/internal/sling/sling_core.go +++ b/internal/sling/sling_core.go @@ -126,6 +126,17 @@ func preflight(opts SlingOpts, deps SlingDeps, querier BeadQuerier) (SlingResult result.DryRun = opts.DryRun result.BeadID = opts.BeadOrFormula result.Method = "bead" + // Honor --nudge even when the route is already in place. The bead is + // routed to the target, but a warm pool slot may have missed its + // wake (its startup nudge was swallowed, or work was routed after it + // went idle). Re-slinging with --nudge must still deliver a wake; + // otherwise the idempotent short-circuit silently drops it and the + // slot sits idle on work it never began. The claim path is + // idempotent/CAS-safe, so a redundant nudge is harmless. Suppressed + // for dry-run, which must not mutate or signal anything. + if opts.Nudge && !opts.DryRun { + result.NudgeAgent = &a + } return result, nil } result.BeadWarnings = append(result.BeadWarnings, check.Warnings...) diff --git a/internal/sling/sling_test.go b/internal/sling/sling_test.go index 1832fdfa69..158028905a 100644 --- a/internal/sling/sling_test.go +++ b/internal/sling/sling_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/gastownhall/gascity/internal/beadmeta" "github.com/gastownhall/gascity/internal/beads" beadsexec "github.com/gastownhall/gascity/internal/beads/exec" "github.com/gastownhall/gascity/internal/config" @@ -3115,6 +3116,76 @@ func TestDoSlingNudgeSignal(t *testing.T) { } } +func TestDoSlingIdempotentHonorsNudge(t *testing.T) { + // A warm pool slot may miss its wake, so re-slinging an already-routed bead + // with --nudge must still surface a nudge signal even though the route is + // idempotent (nothing to re-route). Without this the wake is silently lost. + runner := newFakeRunner() + cfg := &config.City{Workspace: config.Workspace{Name: "test"}} + a := config.Agent{Name: "mayor", MaxActiveSessions: intPtr(1)} + + // Seed a bead already routed to the target so the pre-flight check reports + // idempotent. NoConvoy avoids the convoy-recovery branch, which would fall + // through to a full (non-idempotent) finalize. + routed := beads.Bead{ + ID: "BL-1", + Title: "BL-1", + Type: "task", + Status: "open", + Metadata: map[string]string{ + beadmeta.RoutedToMetadataKey: a.QualifiedName(), + }, + } + store := beads.NewMemStoreFrom(0, []beads.Bead{routed}, nil) + deps := testDeps(cfg, runtime.NewFake(), runner.run) + deps.Store = store + + result, err := DoSling(SlingOpts{ + Target: a, BeadOrFormula: "BL-1", Nudge: true, NoConvoy: true, + }, deps, store) + if err != nil { + t.Fatalf("DoSling: %v", err) + } + if !result.Idempotent { + t.Fatalf("expected idempotent route, got %+v", result) + } + if result.NudgeAgent == nil { + t.Error("expected NudgeAgent to be set on an idempotent sling with Nudge") + } + if len(runner.calls) != 0 { + t.Errorf("idempotent sling must not re-route, got %d runner calls", len(runner.calls)) + } +} + +func TestDoSlingIdempotentDryRunSuppressesNudge(t *testing.T) { + // Dry-run must never signal a nudge even with --nudge on an idempotent route. + runner := newFakeRunner() + cfg := &config.City{Workspace: config.Workspace{Name: "test"}} + a := config.Agent{Name: "mayor", MaxActiveSessions: intPtr(1)} + routed := beads.Bead{ + ID: "BL-1", + Title: "BL-1", + Type: "task", + Status: "open", + Metadata: map[string]string{ + beadmeta.RoutedToMetadataKey: a.QualifiedName(), + }, + } + store := beads.NewMemStoreFrom(0, []beads.Bead{routed}, nil) + deps := testDeps(cfg, runtime.NewFake(), runner.run) + deps.Store = store + + result, err := DoSling(SlingOpts{ + Target: a, BeadOrFormula: "BL-1", Nudge: true, NoConvoy: true, DryRun: true, + }, deps, store) + if err != nil { + t.Fatalf("DoSling: %v", err) + } + if result.NudgeAgent != nil { + t.Error("dry-run idempotent sling must not set NudgeAgent") + } +} + func TestDoSlingSuspendedAgentWarnsEvenOnFailure(t *testing.T) { // Matches gastown-sling tutorial: sling to suspended agent, runner fails, // but AgentSuspended should still be set so CLI prints the warning. From daf17356ca9c4af4a27a7ad47704744c605fc98d Mon Sep 17 00:00:00 2001 From: Stephanie Jarmak <36544495+sjarmak@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:41:03 -0400 Subject: [PATCH 005/225] fix(reconciler): wake warm tmux pool workers when routed work arrives (#1129) (#4083) ## Why A warm idle pool worker on tmux has no wake path when work is routed to it. The reconciler binds the routed bead as the slot's trigger, but the session is already running with an unchanged config fingerprint, so no Start fires and no startup nudge is delivered. The one backstop that would catch this, `nudgeStalledPoolClaims`, is gated off for tmux on the premise that tmux self-heals a missed nudge through its relaunch path. That premise only holds when a session actually restarts; a healthy idle session never does, so the routed bead sits open and unclaimed. This is the structural form of #1129. Split out of a polecat-reliability audit; the runtime-partial fix and the idempotent-sling `--nudge` fix are separate PRs. ## What changed - `cmd/gc/city_runtime.go`: removes the `CanReportActivity` gate on the `nudgeStalledPoolClaims` call so the backstop runs for tmux warm slots. The function keys on the trigger bead still being open and unclaimed and persists bounded observe-then-nudge-then-backoff state on the session bead, so a claim (which flips the bead to in_progress) stops the match; it is churn-free on any runtime. - `cmd/gc/idle_nudge.go`: comment update reflecting the un-gate. - `engdocs/design/idle-claim-nudge-followups.md`: documents the residual gap this does not close (a bead slung to the pool after the slot went idle, and left unassigned, never stamps the slot's `trigger_bead_id`, so it stays invisible), scoped as its own follow-up. ## Coordination note Touches `cmd/gc/city_runtime.go`, also touched by open PRs #3767 and #3772. The changes are in different regions and merge cleanly (verified via a 3-way merge-tree against both); no manual conflict resolution is needed regardless of merge order. ## Test plan - `go build ./...`: clean - `go test ./cmd/gc/ -run IdleClaimNudge`: pass, including new `TestCityRuntimeBeadReconcileTick_IdleClaimNudgeRunsForReportActivityRuntime`, which drives a reconcile tick with a report-activity runtime and asserts the nudge fires (attempt count 0 to 1) where the old gate left it at 0 Co-authored-by: sjarmak --- cmd/gc/city_runtime.go | 24 +++--- cmd/gc/city_runtime_test.go | 79 ++++++++++++++++++++ cmd/gc/idle_nudge.go | 26 ++++--- engdocs/design/idle-claim-nudge-followups.md | 45 +++++++++++ engdocs/design/index.md | 1 + 5 files changed, 157 insertions(+), 18 deletions(-) create mode 100644 engdocs/design/idle-claim-nudge-followups.md diff --git a/cmd/gc/city_runtime.go b/cmd/gc/city_runtime.go index 9ad6adcee0..0a079e0cd4 100644 --- a/cmd/gc/city_runtime.go +++ b/cmd/gc/city_runtime.go @@ -2347,15 +2347,21 @@ func (cr *CityRuntime) beadReconcileTick(ctx context.Context, result DesiredStat recordPhase(TraceSiteControllerTickPhase, "bead_reconcile.nudge_dispatch_tick", phaseStart, nil) // Idle recovery: re-nudge pool slots that are running but never claimed - // their assigned trigger bead. Gated to runtimes the controller cannot see - // activity for (herdr): tmux self-heals a missed startup nudge through its - // relaunch/respawn path and reports activity, so it neither needs nor runs - // this. See nudgeStalledPoolClaims for the churn-free state machine. - if !cr.sp.Capabilities().CanReportActivity { - phaseStart = time.Now() - nudgeStalledPoolClaims(cr.sp, cr.cfg, sessStore, open, assignedWorkBeads, time.Now(), cr.stdout) - recordPhase(TraceSiteControllerTickPhase, "bead_reconcile.nudge_stalled_pool_claims", phaseStart, nil) - } + // their assigned trigger bead. Runs for every runtime, not just herdr. + // tmux's relaunch/respawn path only heals a session that DIED; it does + // nothing for a session that is alive but idle at its prompt on a trigger + // bead it never began (a warm slot resumed onto work whose submit-CR was + // swallowed, or that survived a `gc restart` and was never re-Started). + // Activity reporting lets the controller SEE such a slot as alive but never + // delivers the claim nudge, so tmux has no demand-driven wake for it. The + // backstop is churn-free by construction for either runtime: it keys on the + // trigger bead still being open (the instant a polecat claims, the bead + // flips to in_progress and stops matching), persists its bounded + // observe→nudge→backoff state on the session bead, and never spams a tick. + // See nudgeStalledPoolClaims for the full invariant. + phaseStart = time.Now() + nudgeStalledPoolClaims(cr.sp, cr.cfg, sessStore, open, assignedWorkBeads, time.Now(), cr.stdout) + recordPhase(TraceSiteControllerTickPhase, "bead_reconcile.nudge_stalled_pool_claims", phaseStart, nil) } // recordReconcileTraceInputs records the per-template baseline, the cycle input diff --git a/cmd/gc/city_runtime_test.go b/cmd/gc/city_runtime_test.go index 3e1feab692..09ac0ff6e8 100644 --- a/cmd/gc/city_runtime_test.go +++ b/cmd/gc/city_runtime_test.go @@ -14,6 +14,7 @@ import ( "testing" "time" + "github.com/gastownhall/gascity/internal/beadmeta" "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/beads/contract" "github.com/gastownhall/gascity/internal/config" @@ -3036,6 +3037,84 @@ func TestCityRuntimeBeadReconcileTick_TransientStoreQueryPartialKeepsRunningPool } } +// The idle-claim backstop must run even for a runtime that CAN report activity +// (tmux/fake). Activity reporting makes the controller SEE a warm slot as alive +// but never delivers its claim nudge, so an idle slot handed a trigger bead it +// never began needs this demand-driven wake exactly as herdr does. Before the +// call-site un-gate this was skipped whenever CanReportActivity was true, +// leaving tmux warm slots with no wake path. The marker is pre-seeded past the +// grace window so a single tick nudges (attempt count 0 -> 1). +func TestCityRuntimeBeadReconcileTick_IdleClaimNudgeRunsForReportActivityRuntime(t *testing.T) { + sp := runtime.NewFake() + if !sp.Capabilities().CanReportActivity { + t.Fatal("precondition: fake runtime must report activity for this un-gate test to be meaningful") + } + if err := sp.Start(context.Background(), "worker-bd-idle", runtime.Config{}); err != nil { + t.Fatalf("Start: %v", err) + } + + store := beads.NewMemStore() + staleObs := time.Now().Add(-2 * idleClaimNudgeGrace).UTC().Format(time.RFC3339) + session, err := store.Create(beads.Bead{ + Title: "worker", + Type: sessionBeadType, + Status: "open", + Labels: []string{sessionBeadLabel, "agent:worker"}, + Metadata: map[string]string{ + "session_name": "worker-bd-idle", + "template": "worker", + "agent_name": "worker", + "pool_slot": "1", + poolManagedMetadataKey: boolMetadata(true), + "state": "awake", + "generation": "1", + beadmeta.TriggerBeadIDMetadataKey: "w-idle", + // Pre-seed the backstop marker so we are already past the observe + // grace on attempt 0: a single tick should nudge. + idleClaimNudgeTriggerKey: "w-idle", + idleClaimNudgeCountKey: "0", + idleClaimNudgeAtKey: staleObs, + }, + }) + if err != nil { + t.Fatalf("Create session bead: %v", err) + } + + cr := &CityRuntime{ + cityPath: t.TempDir(), + cityName: "maintainer-city", + cfg: &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(5), Nudge: "Run gc hook --claim --json now."}}}, + sp: sp, + standaloneCityStore: store, + sessionDrains: newDrainTracker(), + rec: events.Discard, + stdout: io.Discard, + stderr: io.Discard, + } + + result := DesiredStateResult{ + State: map[string]TemplateParams{}, + ScaleCheckCounts: map[string]int{"worker": 0}, + AssignedWorkBeads: []beads.Bead{ + // Open + unassigned == unclaimed: the slot's trigger bead the polecat + // never began. workBead sets gc.routed_to but leaves the assignee empty. + workBead("w-idle", "worker", "", "open", 5), + }, + } + cr.beadReconcileTick(context.Background(), result, cr.loadSessionBeadSnapshot(), nil, false) + + got, err := store.Get(session.ID) + if err != nil { + t.Fatalf("Get after tick: %v", err) + } + if got.Status == "closed" { + t.Fatalf("tick unexpectedly closed the idle pool session: %+v", got) + } + if c := got.Metadata[idleClaimNudgeCountKey]; c != "1" { + t.Fatalf("idle-claim nudge did not fire for a report-activity runtime: attempt count = %q, want 1", c) + } +} + func TestCityRuntimeBeadReconcileTick_ScaleCheckPartialKeepsOnlyAffectedPoolSession(t *testing.T) { store := beads.NewMemStore() worker, err := store.Create(beads.Bead{ diff --git a/cmd/gc/idle_nudge.go b/cmd/gc/idle_nudge.go index bf5188c7ba..534c2247b1 100644 --- a/cmd/gc/idle_nudge.go +++ b/cmd/gc/idle_nudge.go @@ -32,15 +32,23 @@ const ( idleClaimNudgeMaxAttempts = 3 // then give up and log (manual re-nudge remains) ) -// nudgeStalledPoolClaims is a reconcile-tick backstop for runtimes the -// controller is blind to (herdr). It re-delivers the claim nudge to a pool slot -// that is running but whose assigned trigger bead is still UNCLAIMED (open, not -// in_progress). Under herdr the startup nudge can be missed — a freshly-spawned -// slot whose submit-CR was swallowed, or a warm slot that survived a `gc -// restart` and was never re-Started — leaving the polecat idle at its prompt -// with work it never began. tmux self-heals that through its relaunch/respawn -// path (and reports activity), so it is gated out at the call site and never -// runs here. +// nudgeStalledPoolClaims is a reconcile-tick backstop that runs for every +// runtime (herdr AND tmux). It re-delivers the claim nudge to a pool slot that +// is running but whose assigned trigger bead is still UNCLAIMED (open, not +// in_progress). The startup nudge can be missed — a freshly-spawned slot whose +// submit-CR was swallowed, or a warm slot that survived a `gc restart` and was +// never re-Started — leaving the polecat idle at its prompt with work it never +// began. tmux's relaunch/respawn path only heals a session that DIED; a live +// idle slot needs this demand-driven wake exactly as herdr does (activity +// reporting makes the controller SEE the slot but never nudges it to claim). +// +// SCOPE (trigger-bead-key limitation): this keys on the slot's own +// gc.trigger_bead_id, so it only rescues a slot the reconciler already bound to +// a specific bead (resume / wake-known-identity tiers). A bead slung to the +// pool AFTER the slot went idle and left UNASSIGNED (routed_to=pool, open, no +// assignee) never stamps trigger_bead_id, so it is invisible here. Widening the +// key to "any open+routed+unclaimed pool bead past the grace window" is the +// documented follow-up (see engdocs/design/idle-claim-nudge-followups.md). // // Churn-free by construction — it inverts every failure mode that got the #312 // idle-session nudger reverted: diff --git a/engdocs/design/idle-claim-nudge-followups.md b/engdocs/design/idle-claim-nudge-followups.md new file mode 100644 index 0000000000..40f3d9ec5e --- /dev/null +++ b/engdocs/design/idle-claim-nudge-followups.md @@ -0,0 +1,45 @@ +# Idle-claim nudge — follow-ups + +The reconcile-tick backstop `nudgeStalledPoolClaims` (cmd/gc/idle_nudge.go) +re-delivers a claim nudge to a pool slot that is running but whose assigned +trigger bead is still unclaimed. It now runs for every runtime (herdr and +tmux); the call-site capability gate was removed because tmux's relaunch/respawn +path only heals a session that died, never a live-but-idle slot, and activity +reporting lets the controller see such a slot without ever waking it to claim. + +## Open follow-up: widen the trigger key to unassigned pool-routed beads + +The backstop keys on the slot's own `gc.trigger_bead_id`. That value is stamped +only when the desired-state builder binds a specific bead to the slot — the +`resume` and `wake-known-identity` tiers, both of which act on work that already +carries an assignee (`cmd/gc/pool_desired_state.go`). A bead slung to the pool +**after** the slot went idle and left **unassigned** (`gc.routed_to = `, +status `open`, no assignee) never stamps `trigger_bead_id`, so it is invisible +to the backstop: `triggerID == ""` short-circuits the loop. + +Result: the un-gate closes the bound-slot case (the reconciler handed this slot +a specific bead, but its submit-CR was swallowed or it survived a `gc restart` +without a re-Start). The scale-from-zero-style case — an unclaimed pool bead +waiting for any warm slot to notice it — is still not woken on tmux. + +### Sketch of the fix + +For each running pool slot with an empty `trigger_bead_id`, look for a bead +where `gc.routed_to` resolves to the slot's template, status is `open`, and the +assignee is empty; past the observe grace, nudge the slot to run its claim hook. + +Constraints to preserve the churn-free property: + +- Keep the persisted `observe → nudge → backoff → give-up` marker, but key it on + the candidate bead id (or the slot when no single candidate dominates) so a + restart cannot replay it. +- The unclaimed pool bead may not be present in the reconciler's + `AssignedWorkBeads` snapshot (that slice is assignment-oriented). The widened + path needs a source of open+routed+unassigned pool beads; confirm which + snapshot already carries them before adding a new read to the hot path. +- Multiple idle slots seeing one unclaimed bead will each nudge. That is bounded + by the grace/backoff/attempt caps and self-limits the instant the first slot + claims (the bead flips to `in_progress`), but measure it before shipping. + +This is deliberately left for its own PR: it changes what the backstop reads, +not just when it runs, and the churn analysis is the load-bearing part. diff --git a/engdocs/design/index.md b/engdocs/design/index.md index 06cadbce3c..4d81da505f 100644 --- a/engdocs/design/index.md +++ b/engdocs/design/index.md @@ -24,6 +24,7 @@ lives in the [Architecture](../architecture/index.md) section. | `beads-dolt-contract-redesign` | Accepted | Canonical bd+Dolt contract, topology commands, migration, and provider-boundary redesign | | `idle-session-sleep` | Accepted | Idle-sleep policy, precedence, and wake mechanics | | `runtime-partial-discipline` | Accepted (source-level), follow-ups Proposed | Treat a failed tmux-liveness observation as partial (defer destructive arms) instead of "nothing running"; mirrors storeQueryPartial | +| `idle-claim-nudge-followups` | Proposed | Widen the stalled-pool-claim backstop key to unassigned pool-routed beads (the case the tmux warm-slot un-gate does not cover) | | `idle-controller-call-rate` | Proposed | Layer-3 (#2463/#3543) cut of the controller's idle bd/Dolt call *rate*: demand-gated ticking, per-pass snapshot, quiescent-scope skipping | | `session-store-fences` | Accepted | Cross-process write fences for session-owned metadata: store facts, flock and token-reread fences, residual convergence-through-persistence | | `named-configured-sessions` | Accepted | Explicit canonical named sessions backed by reusable templates; partially superseded by `session-model-unification` | From 4899854f65fd999628a2c7e4ebff7b7d18b7853d Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 8 Jul 2026 11:48:13 -0700 Subject: [PATCH 006/225] simplify(S08 step-0): delete dead trace symbols + LegacyArms dual field (#4059) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-landable pure-delete slice of S08: removes dead trace symbols and the LegacyArms dual field. The wire-or-delete judgment call for the remaining S08 surface is held back and not included here. Refs #3789. - Gates green - Fable-reviewed, behavior-preserved - Spec: /data/projects/gascity/.claude/worktrees/simplification/engdocs/simplification/specs/S08s0-step0-deletes-spec.md Spike/staged for review, not auto-merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/gc/cmd_trace_test.go | 51 +++--------------------- cmd/gc/session_reconciler_trace_cmd.go | 29 -------------- cmd/gc/session_reconciler_trace_types.go | 24 ----------- 3 files changed, 6 insertions(+), 98 deletions(-) diff --git a/cmd/gc/cmd_trace_test.go b/cmd/gc/cmd_trace_test.go index 046903d477..8c996c533f 100644 --- a/cmd/gc/cmd_trace_test.go +++ b/cmd/gc/cmd_trace_test.go @@ -47,8 +47,8 @@ func TestTraceStartStopStatusOfflineFallback(t *testing.T) { stdout.Reset() stderr.Reset() - if code := cmdTraceStatus(&stdout, &stderr); code != 0 { - t.Fatalf("cmdTraceStatus = %d; stderr=%s", code, stderr.String()) + if code := cmdTraceStatusWithJSON(false, &stdout, &stderr); code != 0 { + t.Fatalf("cmdTraceStatusWithJSON = %d; stderr=%s", code, stderr.String()) } if got := stdout.String(); !strings.Contains(got, "Head seq: 0") || !strings.Contains(got, "repo/polecat") { t.Fatalf("status output = %q, want head_seq and arm info", got) @@ -184,8 +184,8 @@ func TestTraceControllerSocketCommands(t *testing.T) { if err != nil { t.Fatalf("marshal status reply: %v", err) } - if !bytes.Contains(statusPayload, []byte(`"arms"`)) { - t.Fatalf("status reply JSON = %s, want legacy arms alias", statusPayload) + if bytes.Contains(statusPayload, []byte(`"arms"`)) { + t.Fatalf("status reply JSON = %s, must not carry legacy arms alias", statusPayload) } select { case <-pokeCh2: @@ -214,8 +214,8 @@ func TestTraceControllerSocketCommands(t *testing.T) { if err != nil { t.Fatalf("marshal stop status reply: %v", err) } - if !bytes.Contains(stopPayload, []byte(`"arms":[]`)) { - t.Fatalf("stop status reply JSON = %s, want empty legacy arms alias", stopPayload) + if bytes.Contains(stopPayload, []byte(`"arms"`)) { + t.Fatalf("stop status reply JSON = %s, must not carry legacy arms alias", stopPayload) } select { case <-pokeCh3: @@ -224,45 +224,6 @@ func TestTraceControllerSocketCommands(t *testing.T) { } } -func TestTraceStatusJSONAcceptsLegacySocketArms(t *testing.T) { - payload := []byte(`{ - "ok": true, - "status": { - "city_path": "/tmp/trace-town", - "as_of": "2026-05-21T00:00:00Z", - "controller_running": true, - "controller_pid": 123, - "arms": [{ - "scope_type": "template", - "scope_value": "repo/polecat", - "source": "manual", - "level": "detail", - "armed_at": "2026-05-21T00:00:00Z", - "expires_at": "2026-05-21T00:15:00Z", - "last_extended_at": "2026-05-21T00:00:00Z", - "updated_at": "2026-05-21T00:00:00Z" - }] - } - }`) - - var reply traceControlReply - if err := json.Unmarshal(payload, &reply); err != nil { - t.Fatalf("unmarshal legacy trace status reply: %v", err) - } - if reply.Status == nil { - t.Fatal("status is nil") - } - if reply.Status.HeadSeq != 0 { - t.Fatalf("head_seq = %d, want old-controller default 0", reply.Status.HeadSeq) - } - if len(reply.Status.ActiveArms) != 1 { - t.Fatalf("active arms = %#v, want one legacy arm", reply.Status.ActiveArms) - } - if reply.Status.ActiveArms[0].ScopeValue != "repo/polecat" { - t.Fatalf("scope_value = %q, want repo/polecat", reply.Status.ActiveArms[0].ScopeValue) - } -} - func TestTraceControllerSocketInvalidRequestDoesNotPoke(t *testing.T) { server, client := net.Pipe() defer client.Close() //nolint:errcheck diff --git a/cmd/gc/session_reconciler_trace_cmd.go b/cmd/gc/session_reconciler_trace_cmd.go index 154bf7a9ec..a7fa454d5c 100644 --- a/cmd/gc/session_reconciler_trace_cmd.go +++ b/cmd/gc/session_reconciler_trace_cmd.go @@ -46,23 +46,6 @@ type traceStatusJSON struct { ControllerPID int `json:"controller_pid,omitempty"` HeadSeq uint64 `json:"head_seq"` ActiveArms []TraceArm `json:"active_arms"` - LegacyArms []TraceArm `json:"arms"` -} - -func (s *traceStatusJSON) UnmarshalJSON(data []byte) error { - type traceStatusJSONAlias traceStatusJSON - var decoded traceStatusJSONAlias - if err := json.Unmarshal(data, &decoded); err != nil { - return err - } - *s = traceStatusJSON(decoded) - if s.ActiveArms == nil && s.LegacyArms != nil { - s.ActiveArms = traceArmsJSONSlice(s.LegacyArms) - } - if s.LegacyArms == nil && s.ActiveArms != nil { - s.LegacyArms = traceArmsJSONSlice(s.ActiveArms) - } - return nil } type traceStatusResultJSON struct { @@ -341,10 +324,6 @@ func cmdTraceStop(template string, all bool, stdout, stderr io.Writer) int { return 0 } -func cmdTraceStatus(stdout, stderr io.Writer) int { - return cmdTraceStatusWithJSON(false, stdout, stderr) -} - func cmdTraceStatusWithJSON(jsonOut bool, stdout, stderr io.Writer) int { cityPath, err := resolveCity() if err != nil { @@ -736,15 +715,7 @@ func traceStatusFromState(cityPath string, state TraceArmState, now time.Time) t ControllerPID: pid, HeadSeq: head, ActiveArms: arms, - LegacyArms: traceArmsJSONSlice(arms), - } -} - -func traceArmsJSONSlice(arms []TraceArm) []TraceArm { - if len(arms) == 0 { - return []TraceArm{} } - return append([]TraceArm(nil), arms...) } func traceSocketControl(cityPath, command string, req traceControlRequest) (*traceStatusJSON, string, error) { diff --git a/cmd/gc/session_reconciler_trace_types.go b/cmd/gc/session_reconciler_trace_types.go index a2d538de6a..3db5f8f25c 100644 --- a/cmd/gc/session_reconciler_trace_types.go +++ b/cmd/gc/session_reconciler_trace_types.go @@ -295,9 +295,7 @@ type TraceEvaluationStatus string const ( TraceEvaluationEligible TraceEvaluationStatus = "eligible" TraceEvaluationDependencyBlocked TraceEvaluationStatus = "dependency_blocked" - TraceEvaluationCapRejected TraceEvaluationStatus = "cap_rejected" TraceEvaluationStorePartial TraceEvaluationStatus = "store_partial" - TraceEvaluationMissingTemplate TraceEvaluationStatus = "missing_template" TraceEvaluationSkipped TraceEvaluationStatus = "skipped" ) @@ -332,28 +330,6 @@ const ( TraceArmSourceAuto TraceArmSource = "auto" ) -type TraceTextBlob struct { - Value string `json:"value"` - OriginalBytes int `json:"original_bytes"` - StoredBytes int `json:"stored_bytes"` - Truncated bool `json:"truncated"` -} - -func NewTraceTextBlob(value string, maxBytes int) TraceTextBlob { - b := []byte(value) - blob := TraceTextBlob{ - Value: value, - OriginalBytes: len(b), - StoredBytes: len(b), - } - if maxBytes > 0 && len(b) > maxBytes { - blob.Value = string(b[:maxBytes]) - blob.StoredBytes = maxBytes - blob.Truncated = true - } - return blob -} - type SessionReconcilerTraceRecord struct { TraceSchemaVersion int `json:"trace_schema_version"` Seq uint64 `json:"seq"` From 55b15769cdbdef8cd8a5a805e242decc8e7b5f7c Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 8 Jul 2026 11:58:08 -0700 Subject: [PATCH 007/225] feat(dashboard): push run-detail on session events (idle-run session-link freshness) (#3951) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What The follow-up to the run-detail SSE stream (#3942): close the idle-run session-link staleness the stream shipped with. The per-run detail stream only pushed when the **bead fold** changed (a bead event fired `build()` → `notifySubscribers`). Session lifecycle events (`session.updated`/`woke`/`stopped`/…) change the live session links the detail projection layers on, but **not** the bead fold — so on an otherwise-idle run a session-link flip stayed stale until the next bead event or the 3s sessions TTL. `foldNext` now detects `session.*` events in the tail (they share the `session.` prefix; `proj.Apply` ignores them) and calls `refreshSessionEnrichment`: - `sessionsCache.invalidate(city)` — a new `singleFlightCache.invalidate` that expires the entry (sets ttl 0) while **preserving the last-good value and the monotonic version** (a delete would reset the version and risk a memo-key collision), and - `notifySubscribers()` — wake the detail-stream subscribers. Each subscriber then rebuilds via `detail()`, refetches the now-expired sessions (single-flight collapses concurrent rebuilds to one loopback read), and the per-connection **byte-dedup** drops the frame when the run's own links didn't move (e.g. the event was for an unrelated session in the same city). ## Correctness (adversarially reviewed) - **No fold-storm:** the projector advances `LastSeq` for every event and the byte offset advances past the session event, so it is never re-folded — **at most one refresh per tail poll**. (The rare rotation-catch-up path is left to recover via the next poll / TTL.) - **Not a no-op:** session state genuinely feeds the detail bytes (`RunSessionLink` via `resolveRunSessionLink`, `progress.sessionLinkCount`), so a real session change produces different bytes and the dedup emits a frame. - **Concurrency:** `invalidate` is race-clean under the cache lock; invalidate-before-notify ordering means a woken subscriber recomputes. One documented narrow window: an `invalidate` can be masked by a compute that elected before it (self-heals on the next event / reset TTL) — matches the cache's eventual-consistency contract. ## Tests - `TestFoldNextSessionEventRefreshesSessionsAndNotifies` — the mechanism: a `session.*`-only event bumps the generation + invalidates the sessions cache. **Verified it fails without the change** (generation never advances). - `TestRunDetailStreamPushesFrameOnSessionAliasFlip` — **end-to-end**: a session-linked run, a stateful supervisor flipping the linked session's alias `alpha-worker`→`beta-worker`, a `session.updated` (no bead event) → asserts a **second SSE frame arrives with the moved link bytes** (and dedup let it through). Verified it hangs+fails without the change. `go test -race ./internal/api/dashboardbff/...`, `make dashboard-check`, `go vet` — all green. No OpenAPI/wire change. Stacked on #3949 (the typecheck:test gate) → #3943 → the run-detail stack. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- internal/api/dashboardbff/enrichment_cache.go | 18 ++ .../rundetail_session_push_test.go | 286 ++++++++++++++++++ internal/api/dashboardbff/runtailer.go | 47 +++ 3 files changed, 351 insertions(+) create mode 100644 internal/api/dashboardbff/rundetail_session_push_test.go diff --git a/internal/api/dashboardbff/enrichment_cache.go b/internal/api/dashboardbff/enrichment_cache.go index 6dc99c9bf9..b4257c9ea4 100644 --- a/internal/api/dashboardbff/enrichment_cache.go +++ b/internal/api/dashboardbff/enrichment_cache.go @@ -305,6 +305,24 @@ func (c *singleFlightCache[K, V]) lastGoodOrZero(key K) (V, uint64, bool) { return zero, 0, false } +// invalidate forces the next get for key to recompute — and bump the version — +// even within its TTL, while preserving the last-good value (for serve-stale) +// and the monotonic version. It expires the entry rather than deleting it so the +// version counter keeps advancing (a delete would reset it to zero and could +// collide with a memo key). Used to eagerly refresh an enrichment the moment an +// out-of-band signal says it changed (e.g. a session.* event in the tail), +// rather than waiting for the TTL to lapse. A no-op if the key is absent. +func (c *singleFlightCache[K, V]) invalidate(key K) { + c.mu.Lock() + if e, ok := c.entries[key]; ok { + // ttl 0 makes the fresh-hit check (time.Since(computed) < ttl) always + // false, so the next get recomputes. An in-flight compute is unaffected — + // it publishes and bumps the version as usual. + e.ttl = 0 + } + c.mu.Unlock() +} + // ── Cached payload shapes ───────────────────────────────────────────────── // cachedSessions is the value stored in the sessions cache: the projected diff --git a/internal/api/dashboardbff/rundetail_session_push_test.go b/internal/api/dashboardbff/rundetail_session_push_test.go new file mode 100644 index 0000000000..910ff93bfc --- /dev/null +++ b/internal/api/dashboardbff/rundetail_session_push_test.go @@ -0,0 +1,286 @@ +package dashboardbff + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/events" +) + +// TestFoldNextSessionEventRefreshesSessionsAndNotifies proves that a session +// lifecycle event observed in the tail — with NO accompanying bead event, so the +// bead fold does not change and build() does not fire — still (a) expires the +// per-city sessions cache and (b) wakes any detail-stream subscribers (via +// notifySubscribers). That is what lets an idle run's session-link flip +// push over the SSE stream promptly instead of waiting for the next bead event +// or the sessions TTL. +func TestFoldNextSessionEventRefreshesSessionsAndNotifies(t *testing.T) { + defer func(prev time.Duration) { runTailPollInterval = prev }(runTailPollInterval) + runTailPollInterval = 15 * time.Millisecond + + // A counting supervisor so we can prove the sessions cache was invalidated: a + // read after the session event must re-hit upstream (the cached entry expired). + var sessionsHits atomic.Int64 + supervisor := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/sessions") { + sessionsHits.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[],"total":0}`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer supervisor.Close() + + dir := t.TempDir() + logPath := filepath.Join(dir, ".gc", "events.jsonl") + writeEventLog(t, logPath, + runDetailRootEvent(), + runDetailStepEvent(2, "run1.1", "run1", "preflight", "in_progress"), + ) + p := New(Deps{ + Resolver: fakeResolver{paths: map[string]string{"alpha": dir}}, + SupervisorBaseURL: supervisor.URL, + }) + p.Start(t.Context()) + defer p.Stop() + tl, _ := p.cityRunTailer("alpha") + select { + case <-tl.readyCh: + case <-time.After(2 * time.Second): + t.Fatal("cold replay did not complete") + } + ctx := context.Background() + + // Warm the sessions cache (a hit if the eager prime hasn't already), then + // snapshot the hit count. + if _, ok := tl.mgr.fetchSessions(ctx, "alpha"); !ok { + t.Fatal("sessions prime must be available") + } + hitsBefore := sessionsHits.Load() + + // Subscribe to the detail stream so we can observe the wakeup a session event + // triggers, then drain any notify already pending from the cold replay / prime + // so the next receive is unambiguously the session event's notify. + sub := tl.subscribe() + defer tl.unsubscribe(sub) + select { + case <-sub.notify: + default: + } + + // Append a session lifecycle event ONLY — seq past the fold cursor, no bead + // change, so proj.Apply ignores it and build() (its subscriber notify) never + // fires. Only the new session-aware path can react to it. + appendEvents(t, logPath, events.Event{ + Type: events.SessionUpdated, + Seq: currentLastSeq(tl) + 1, + Ts: time.Now(), + Subject: "alpha__worker-1", + }) + + // The tail folds it: containsSessionEvent → refreshSessionEnrichment → + // invalidate(sessions) + notifySubscribers → our subscriber wakes. + select { + case <-sub.notify: + case <-time.After(2 * time.Second): + t.Fatal("session event did not notify detail-stream subscribers within 2s") + } + + // The cache was invalidated: the next read re-hits upstream. + if _, ok := tl.mgr.fetchSessions(ctx, "alpha"); !ok { + t.Fatal("post-event sessions read must be available") + } + if got := sessionsHits.Load(); got <= hitsBefore { + t.Fatalf("sessions upstream hits = %d, want > %d (a session event must invalidate the cache)", got, hitsBefore) + } +} + +// sessionLinkedStepEvent builds a step bead that resolves a session link: an +// in_progress status (→ presentation "active", not pending/ready) plus a +// session_id in metadata. detail()'s session enrichment then joins that id +// against the /v0 sessions read, so the resolved link's sessionName tracks the +// live session's alias — the exact field a session.updated must be able to move. +func sessionLinkedStepEvent(seq uint64, sessionID string) events.Event { + return beadCreatedEvent(seq, beads.Bead{ + ID: "run1.1", + Title: "preflight", + Status: "in_progress", + Type: "task", + ParentID: "run1", + Ref: "mol-adopt-pr-v2.preflight", + CreatedAt: time.Date(2026, 6, 1, 10, 1, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 6, 1, 10, 5, 0, 0, time.UTC), + Metadata: map[string]string{ + "gc.kind": "step", + "gc.root_bead_id": "run1", + "gc.step_id": "preflight", + "gc.scope_ref": "demo", + "session_id": sessionID, + }, + }) +} + +// TestRunDetailStreamPushesFrameOnSessionAliasFlip is the end-to-end proof the +// mechanism test cannot give: a real session change flows all the way to a pushed +// SSE frame. A run resolves a session link (step bead → session_id gc-333573), +// the stateful fake supervisor first reports that session with alias +// "alpha-worker", then flips it to "beta-worker". A session.updated event (NO +// bead event, so the bead fold is unchanged and build() never fires) drives +// foldNext → refreshSessionEnrichment → invalidate(sessions) + notify → each +// subscriber rebuilds detail(), refetches the now-expired sessions, and the +// per-connection byte-dedupe lets the frame through BECAUSE the resolved link's +// sessionName actually moved. This closes the invalidate→refetch→rebuild→ +// new-bytes→frame gap the empty-sessions mechanism test leaves open. +func TestRunDetailStreamPushesFrameOnSessionAliasFlip(t *testing.T) { + defer func(prev time.Duration) { runTailPollInterval = prev }(runTailPollInterval) + runTailPollInterval = 15 * time.Millisecond + defer func(prev time.Duration) { runDetailStreamHeartbeat = prev }(runDetailStreamHeartbeat) + runDetailStreamHeartbeat = time.Hour // keep heartbeats out of the frame stream + + const sessionID = "gc-333573" + // flipped=false → alias "alpha-worker"; flipped=true → alias "beta-worker". + var flipped atomic.Bool + supervisor := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/sessions") { + w.WriteHeader(http.StatusNotFound) + return + } + alias := "alpha-worker" + if flipped.Load() { + alias = "beta-worker" + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"items":[{"id":%q,"alias":%q,"state":"active","running":true}],"total":1}`, sessionID, alias) + })) + defer supervisor.Close() + + dir := t.TempDir() + logPath := filepath.Join(dir, ".gc", "events.jsonl") + writeEventLog(t, logPath, + runDetailRootEvent(), + sessionLinkedStepEvent(2, sessionID), + ) + p := New(Deps{ + Resolver: fakeResolver{paths: map[string]string{"alpha": dir}}, + SupervisorBaseURL: supervisor.URL, + }) + p.Start(t.Context()) + defer p.Stop() + + srv := httptest.NewServer(p.Handler()) + defer srv.Close() + + resp, sc, closeStream := startDetailStream(t, srv) + defer closeStream() + _ = resp + + // First frame: the link resolves against the initial "alpha-worker" alias. + first, ok := readSSEFrame(t, sc) + if !ok { + t.Fatal("no first frame") + } + if !sessionLinkNameEquals(t, first.data, "alpha-worker") { + t.Fatalf("first frame session link name != alpha-worker; data=%q", first.data) + } + + // Flip the supervisor's alias, then land a session.updated ONLY (no bead + // event). The bead fold does not change, so only the session-aware push path + // can surface the new alias. + flipped.Store(true) + appendEvents(t, logPath, events.Event{ + Type: events.SessionUpdated, + Seq: currentLastSeq(tl(t, p, "alpha")) + 1, + Ts: time.Now(), + Subject: "alpha__" + sessionID, + }) + + // The pushed frame must reflect the moved link name — proving invalidate → + // refetch → rebuild → new bytes → frame end-to-end, and that byte-dedupe did + // NOT suppress it (the run's own link genuinely moved). Bounded so a + // regression (no push) fails promptly here rather than hanging to the test + // timeout. + next, ok := readSSEFrameWithin(t, sc, 2*time.Second) + if !ok { + t.Fatal("no push frame after session.updated (the session alias flip did not reach the SSE stream)") + } + if !sessionLinkNameEquals(t, next.data, "beta-worker") { + t.Fatalf("push frame session link name != beta-worker (session alias flip did not reach the frame); data=%q", next.data) + } + if next.data == first.data { + t.Fatal("push frame bytes equal the first frame — byte-dedupe should have let the moved link through") + } +} + +// readSSEFrameWithin reads one SSE frame but gives up after d, returning +// (zero,false) on the deadline so a "no push" regression fails fast instead of +// blocking on the scanner until the whole test times out. The reader goroutine +// is abandoned on timeout (the deferred stream close unblocks it), which is +// acceptable in a test. +func readSSEFrameWithin(t *testing.T, sc *bufio.Scanner, d time.Duration) (sseFrame, bool) { + t.Helper() + type res struct { + frame sseFrame + ok bool + } + ch := make(chan res, 1) + go func() { + f, ok := readSSEFrame(t, sc) + ch <- res{f, ok} + }() + select { + case r := <-ch: + return r.frame, r.ok + case <-time.After(d): + return sseFrame{}, false + } +} + +// tl resolves the started tailer for a city in a test. +func tl(t *testing.T, p *Plane, city string) *cityRunTailer { + t.Helper() + tailer, ok := p.cityRunTailer(city) + if !ok { + t.Fatalf("no tailer for city %q", city) + } + return tailer +} + +// sessionLinkNameEquals reports whether the run detail in data carries an +// attached session link whose sessionName equals want on any execution instance. +func sessionLinkNameEquals(t *testing.T, data, want string) bool { + t.Helper() + var detail struct { + Nodes []struct { + ExecutionInstances []struct { + Session struct { + Kind string `json:"kind"` + Link struct { + SessionName string `json:"sessionName"` + } `json:"link"` + } `json:"session"` + } `json:"executionInstances"` + } `json:"nodes"` + } + if err := json.Unmarshal([]byte(data), &detail); err != nil { + t.Fatalf("decode detail frame: %v; data=%q", err, data) + } + for _, n := range detail.Nodes { + for _, inst := range n.ExecutionInstances { + if inst.Session.Kind == "attached" && inst.Session.Link.SessionName == want { + return true + } + } + } + return false +} diff --git a/internal/api/dashboardbff/runtailer.go b/internal/api/dashboardbff/runtailer.go index 7cee9925ee..cafbce53f9 100644 --- a/internal/api/dashboardbff/runtailer.go +++ b/internal/api/dashboardbff/runtailer.go @@ -320,9 +320,56 @@ func (t *cityRunTailer) foldNext(proj *runproj.Projector, st *tailState) { if len(fresh) == 0 { return } + sessionChanged := containsSessionEvent(fresh) if proj.Apply(fresh) { st.marks = t.build(proj, st.marks, nil) } + if sessionChanged { + // Session lifecycle events don't change the bead fold (proj.Apply ignores + // them), so build() — and its subscriber notify — may not have fired. But + // they DO change the live session links the detail projection layers on, so + // eagerly refresh the sessions enrichment and wake the detail-stream + // subscribers: an idle run's session-link flip then pushes without waiting + // for the next bead event or the sessions TTL. Rare session events that land + // only in the rotation catch-up path recover on the next poll / the TTL. + t.refreshSessionEnrichment() + } +} + +// sessionEventPrefix is the common prefix of every session lifecycle event +// (session.updated / .woke / .stopped / .crashed / …). +const sessionEventPrefix = "session." + +// containsSessionEvent reports whether any freshly-folded event is a session +// lifecycle event. Such events do not change the bead fold, so build() ignores +// them, but they change the live session enrichment the detail projection layers +// on — the reason foldNext refreshes sessions and wakes the detail stream. +func containsSessionEvent(fresh []events.Event) bool { + for i := range fresh { + if strings.HasPrefix(fresh[i].Type, sessionEventPrefix) { + return true + } + } + return false +} + +// refreshSessionEnrichment eagerly expires the per-city sessions cache and wakes +// the detail-stream subscribers so a session-link change on an otherwise-idle +// run pushes a fresh frame promptly. Each subscriber rebuilds via detail(), +// which refetches the now-expired sessions (single-flight collapses concurrent +// rebuilds to one loopback read); the per-connection byte-dedupe drops the frame +// when the run's own links did not move — e.g. the event was for an unrelated +// session in the same city. It is naturally rate-limited to at most once per +// tail poll (runTailPollInterval). +// +// The invalidate can be masked by a sessions compute that elected BEFORE it: that +// in-flight compute's deferred publish resets the TTL and bumps the version with a +// value that may predate this session change, so a subscriber joining it can push +// one transiently-stale frame. This matches the cache's eventual-consistency +// contract and self-heals on the next session/bead event or the reset TTL. +func (t *cityRunTailer) refreshSessionEnrichment() { + t.mgr.sessionsCache.invalidate(t.name) + t.notifySubscribers() } // eventsAfter keeps only events past the projector's cursor, dropping the From bb9c90d739ba152acd3a6aeffce8b3cce5b3e9af Mon Sep 17 00:00:00 2001 From: Stephanie Jarmak <36544495+sjarmak@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:56:07 -0400 Subject: [PATCH 008/225] fix(session): confirm orphans dead before (re)starting a session; subreaper-aware reap; token-fenced async stop (#4089) ## Why Two agent processes could end up working one bead because a respawn happened while the old process was still alive. Four related process-lifecycle defects, from a prior audit. ## What changed - **Fail-closed orphan scan** (`internal/runtime/tmux/adapter.go`, `internal/session/manager.go`): when `ListRunning` errors during the pre-start orphan scan, `FindRuntimesBySessionID` no longer marks every scanned process "tracked" (which made `killExistingOrphans` skip them and let `Start` proceed). It returns the same-session roots untracked so they are killed, bounded to same-session-ID + same-city. - **Confirmed-dead-before-start** (`internal/runtime/proctable/kill_unix.go`, `internal/runtime/process_control.go`): after SIGKILL, `KillByPID` waits (bounded by `ManagedProcessReapGrace`, 3s) until the pid is gone-or-zombie and returns an error if it survives. **All four** `killExistingOrphans` call sites now gate on that error before starting: the `Create` path plus the three resume/respawn paths (`ensureRunning`, `ensureRunningRuntimeOnly`, `retryFreshStartAfterStaleKey`), which operate on a stable reused bead ID and are where a surviving orphan actually occurs. A recycled pid is disambiguated by `/proc//stat` start-time so it isn't misread as still-alive. - **Subreaper-aware orphan reap** (`internal/runtime/tmux/tmux.go`, `internal/workspacesvc/orphan_reap.go`): the reparent test is now "parent outside the known descendant set" (tmux) / `ppid == 1 OR ppid == detected subreaper pid` (workspacesvc), so orphans that reparent to `systemd --user` under a `user@.service` subreaper are still collected. Detection failure falls back to strict `ppid == 1` on plain-init hosts. - **Token-fenced async drain-ack stop** (`cmd/gc/session_reconciler.go`): `queueDrainAckAsyncStop` threads the expected `GC_INSTANCE_TOKEN` (captured at queue time) and skips the kill on a definite mismatch, so a stalled async kill can't hit a name-reused replacement, mirroring `verifiedStop`. ## Test plan - `go build ./...`, `go vet`, `golangci-lint run ./internal/session/... ./internal/runtime/proctable/...`: clean (0 issues) - `go test ./internal/session/... ./internal/runtime/... ./internal/workspacesvc/... ./internal/pidutil/... ./cmd/gc/...`: pass - New tests: `KillByPID` confirms death before returning; PID-reuse disambiguation (matching/recycled/empty); reparent collects init + subreaper orphans, skips known descendants; subreaper detection (systemd-user / plain-init / cyclic); token-fence skips a reused name and kills a matching session; and behavioral tests that `Start`/`StartRuntimeOnly` refuse (no `start:` event) and unwind the route when an orphan can't be confirmed dead, plus positive cases proving no over-refusal. The refusal tests were verified to fail if the gate is stubbed to a no-op. ## Note A genuinely-wedged orphan can add up to `ManagedProcessStopGrace + ManagedProcessReapGrace` (~8s) to a single `Create`/respawn call while it confirms death, then refuses that attempt (retried next tick). It is bounded and on the per-request/respawn goroutine, not a shared serialized loop, but a caller behind a tight synchronous SLA will see the occasional multi-second stall when an orphan is actually stuck. --------- Co-authored-by: sjarmak Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/gc/session_reconciler.go | 23 +- cmd/gc/session_reconciler_test.go | 90 ++++++- internal/pidutil/pidutil.go | 62 +++++ internal/pidutil/pidutil_test.go | 65 +++++ internal/runtime/process_control.go | 8 + internal/runtime/proctable/kill_unix.go | 78 ++++-- internal/runtime/proctable/kill_unix_test.go | 76 ++++++ internal/runtime/tmux/adapter.go | 27 +- internal/runtime/tmux/tmux.go | 46 ++-- internal/runtime/tmux/tmux_test.go | 19 +- internal/runtime/tmux/tmux_unit_test.go | 41 +++ internal/session/chat.go | 34 ++- internal/session/manager.go | 30 ++- internal/session/manager_test.go | 251 ++++++++++++++++++- internal/workspacesvc/orphan_reap.go | 71 +++++- internal/workspacesvc/orphan_reap_test.go | 65 +++++ 16 files changed, 913 insertions(+), 73 deletions(-) diff --git a/cmd/gc/session_reconciler.go b/cmd/gc/session_reconciler.go index 1f7fd203bf..3ce651cf3f 100644 --- a/cmd/gc/session_reconciler.go +++ b/cmd/gc/session_reconciler.go @@ -283,7 +283,7 @@ func drainAckAsyncStopKey(sessionID, name string) string { // for the async drain-ack stop path (see queueDrainAckAsyncStop). var drainAckAsyncStopPokeController = pokeController -func queueDrainAckAsyncStop(cityPath string, store beads.Store, sp runtime.Provider, cfg *config.City, sessionID, name string, tracker *asyncStartTracker, stderr io.Writer) { +func queueDrainAckAsyncStop(cityPath string, store beads.Store, sp runtime.Provider, cfg *config.City, sessionID, name, expectedToken string, tracker *asyncStartTracker, stderr io.Writer) { name = strings.TrimSpace(name) if name == "" || sp == nil { return @@ -303,6 +303,19 @@ func queueDrainAckAsyncStop(cityPath string, store beads.Store, sp runtime.Provi } done() }() + // Token fence (mirrors verifiedStop): this kill targets the session by + // NAME and may fire long after it was queued. If the name was reused by + // a re-woken replacement in the meantime, its GC_INSTANCE_TOKEN differs + // from the one we intended to stop; killing it would take out a live, + // working session. Skip on a definite mismatch. An empty expected or + // live token means "cannot verify" and falls through to the kill, + // matching verifiedStop's conservative posture. + if expectedToken != "" { + if actualToken, _ := sp.GetMeta(name, "GC_INSTANCE_TOKEN"); actualToken != "" && actualToken != expectedToken { + fmt.Fprintf(stderr, "session reconciler: async drain-ack stop %s skipped: instance token mismatch (session was replaced)\n", name) //nolint:errcheck + return + } + } if err := workerKillSessionTargetWithConfig(cityPath, store, sp, cfg, name); err != nil && !runtime.IsSessionGone(err) { fmt.Fprintf(stderr, "session reconciler: async drain-ack stop %s: %v\n", name, err) //nolint:errcheck return @@ -577,7 +590,7 @@ func reconcileDrainAckStopPending( // mutates only the async tracker, so the bead is untouched and the snapshot // stays coherent — a zero result (applyTo no-op) matches the old refresh of // the unmutated bead. - queueDrainAckAsyncStop(cityPath, store, sp, cfg, session.ID, name, asyncStopTracker, stderr) + queueDrainAckAsyncStop(cityPath, store, sp, cfg, session.ID, name, session.Metadata["instance_token"], asyncStopTracker, stderr) return true, drainAckFinalizeResult{} } return true, finalizeDrainAckStoppedSession( @@ -620,7 +633,7 @@ func finalizeDrainAckStopPendingSessions( name := strings.TrimSpace(info.SessionNameMetadata) obs, err := workerObserveSessionTargetWithRuntimeHintsWithConfig(cityPath, store, sp, cfg, session.ID, nil) if err != nil || obs.Running || obs.Alive { - queueDrainAckAsyncStop(cityPath, store, sp, cfg, session.ID, name, asyncStopTracker, stderr) + queueDrainAckAsyncStop(cityPath, store, sp, cfg, session.ID, name, session.Metadata["instance_token"], asyncStopTracker, stderr) continue } // Pool-managed stop-pending beads close here instead of staying open as @@ -1871,7 +1884,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // reader. Pre-pass-masked (STEP6-PREPASS-AUDIT group 3). infoByID[session.ID] = infoByID[session.ID].ApplyPatch(sessionpkg.DrainAckStopPendingPatch(clk.Now().UTC())) clearDrainTrackerForStopPending(session, dt) - queueDrainAckAsyncStop(cityPath, store, sp, cfg, session.ID, name, asyncStopTracker, stderr) + queueDrainAckAsyncStop(cityPath, store, sp, cfg, session.ID, name, session.Metadata["instance_token"], asyncStopTracker, stderr) if trace != nil { trace.RecordDecision(TraceSiteReconcilerDrainAck, TraceReasonOrphaned, TraceOutcomeStopPending, template, name, nil) } @@ -2218,7 +2231,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // orphan-arm site above (STEP6-PREPASS-AUDIT group 3). infoByID[session.ID] = infoByID[session.ID].ApplyPatch(sessionpkg.DrainAckStopPendingPatch(clk.Now().UTC())) clearDrainTrackerForStopPending(session, dt) - queueDrainAckAsyncStop(cityPath, store, sp, cfg, session.ID, name, asyncStopTracker, stderr) + queueDrainAckAsyncStop(cityPath, store, sp, cfg, session.ID, name, session.Metadata["instance_token"], asyncStopTracker, stderr) if trace != nil { trace.RecordDecision(TraceSiteReconcilerDrainAck, TraceReasonAcknowledged, TraceOutcomeStopPending, tp.TemplateName, name, nil) } diff --git a/cmd/gc/session_reconciler_test.go b/cmd/gc/session_reconciler_test.go index 30e6e4bb3c..4fd873e412 100644 --- a/cmd/gc/session_reconciler_test.go +++ b/cmd/gc/session_reconciler_test.go @@ -1089,7 +1089,7 @@ func TestQueueDrainAckAsyncStopTracksShutdownWait(t *testing.T) { } var stderr synchronizedBuffer tracker := &asyncStartTracker{} - queueDrainAckAsyncStop("", store, sp, &config.City{}, "gc-worker", "worker", tracker, &stderr) + queueDrainAckAsyncStop("", store, sp, &config.City{}, "gc-worker", "worker", "", tracker, &stderr) select { case <-sp.stopStarted: @@ -1130,14 +1130,14 @@ func TestQueueDrainAckAsyncStopDedupScopedToTracker(t *testing.T) { var stderr synchronizedBuffer firstTracker := &asyncStartTracker{} secondTracker := &asyncStartTracker{} - queueDrainAckAsyncStop("", store, first, &config.City{}, "gc-worker", "worker", firstTracker, &stderr) + queueDrainAckAsyncStop("", store, first, &config.City{}, "gc-worker", "worker", "", firstTracker, &stderr) select { case <-first.stopStarted: case <-time.After(time.Second): t.Fatal("first async drain-ack stop did not start") } - queueDrainAckAsyncStop("", store, second, &config.City{}, "gc-worker", "worker", secondTracker, &stderr) + queueDrainAckAsyncStop("", store, second, &config.City{}, "gc-worker", "worker", "", secondTracker, &stderr) select { case <-second.stopStarted: case <-time.After(time.Second): @@ -1163,7 +1163,7 @@ func TestQueueDrainAckAsyncStopRecoversStopPanic(t *testing.T) { } var stderr synchronizedBuffer tracker := &asyncStartTracker{} - queueDrainAckAsyncStop(t.TempDir(), store, sp, &config.City{}, "gc-worker", "worker", tracker, &stderr) + queueDrainAckAsyncStop(t.TempDir(), store, sp, &config.City{}, "gc-worker", "worker", "", tracker, &stderr) select { case <-sp.stopStarted: @@ -1206,7 +1206,7 @@ func TestQueueDrainAckAsyncStopPokesAfterSuccessfulStop(t *testing.T) { } var stderr synchronizedBuffer tracker := &asyncStartTracker{} - queueDrainAckAsyncStop("", store, sp, &config.City{}, "gc-worker", "worker", tracker, &stderr) + queueDrainAckAsyncStop("", store, sp, &config.City{}, "gc-worker", "worker", "", tracker, &stderr) if !tracker.wait(time.Second) { t.Fatal("async drain-ack stop did not complete") } @@ -1243,7 +1243,7 @@ func TestQueueDrainAckAsyncStopDoesNotPokeOnHardError(t *testing.T) { sp.StopErrors = map[string]error{"worker": errors.New("hard kill error")} var stderr synchronizedBuffer tracker := &asyncStartTracker{} - queueDrainAckAsyncStop("", store, sp, &config.City{}, "gc-worker", "worker", tracker, &stderr) + queueDrainAckAsyncStop("", store, sp, &config.City{}, "gc-worker", "worker", "", tracker, &stderr) if !tracker.wait(time.Second) { t.Fatal("async drain-ack stop did not complete") } @@ -1256,6 +1256,82 @@ func TestQueueDrainAckAsyncStopDoesNotPokeOnHardError(t *testing.T) { } } +// TestQueueDrainAckAsyncStopTokenFenceSkipsReusedName verifies the async +// drain-ack stop refuses to kill when the runtime's live GC_INSTANCE_TOKEN no +// longer matches the token captured when the stop was queued: by kill time the +// name has been reused by a re-woken replacement, and killing it would take out +// a live session (mirrors verifiedStop). +// Not parallel — modifies the package-level drainAckAsyncStopPokeController seam. +func TestQueueDrainAckAsyncStopTokenFenceSkipsReusedName(t *testing.T) { + var pokeCalls int + var pokeMu sync.Mutex + old := drainAckAsyncStopPokeController + drainAckAsyncStopPokeController = func(string) error { + pokeMu.Lock() + pokeCalls++ + pokeMu.Unlock() + return nil + } + t.Cleanup(func() { drainAckAsyncStopPokeController = old }) + + store := beads.NewMemStore() + sp := runtime.NewFake() + if err := sp.Start(context.Background(), "worker", runtime.Config{Command: "test-cmd"}); err != nil { + t.Fatalf("Start: %v", err) + } + // The live session belongs to a replacement with a fresh token. + if err := sp.SetMeta("worker", "GC_INSTANCE_TOKEN", "live-token"); err != nil { + t.Fatalf("SetMeta: %v", err) + } + + var stderr synchronizedBuffer + tracker := &asyncStartTracker{} + // We queued the stop for the OLD session (stale token). + queueDrainAckAsyncStop("", store, sp, &config.City{}, "gc-worker", "worker", "stale-token", tracker, &stderr) + if !tracker.wait(time.Second) { + t.Fatal("async drain-ack stop did not complete") + } + + if !sp.IsRunning("worker") { + t.Fatal("token-fenced async stop killed the name-reused replacement") + } + if got := stderr.String(); !strings.Contains(got, "instance token mismatch") { + t.Fatalf("stderr = %q, want token mismatch diagnostic", got) + } + pokeMu.Lock() + got := pokeCalls + pokeMu.Unlock() + if got != 0 { + t.Fatalf("poke count = %d, want 0 (fenced stop must not poke)", got) + } +} + +// TestQueueDrainAckAsyncStopTokenFenceKillsMatchingSession verifies the fence +// lets the kill proceed when the live token matches the queued token. +func TestQueueDrainAckAsyncStopTokenFenceKillsMatchingSession(t *testing.T) { + store := beads.NewMemStore() + sp := runtime.NewFake() + if err := sp.Start(context.Background(), "worker", runtime.Config{Command: "test-cmd"}); err != nil { + t.Fatalf("Start: %v", err) + } + if err := sp.SetMeta("worker", "GC_INSTANCE_TOKEN", "live-token"); err != nil { + t.Fatalf("SetMeta: %v", err) + } + + var stderr synchronizedBuffer + tracker := &asyncStartTracker{} + queueDrainAckAsyncStop("", store, sp, &config.City{}, "gc-worker", "worker", "live-token", tracker, &stderr) + if !tracker.wait(time.Second) { + t.Fatal("async drain-ack stop did not complete") + } + if sp.IsRunning("worker") { + t.Fatal("matching-token async stop did not kill the session") + } + if got := stderr.String(); strings.Contains(got, "instance token mismatch") { + t.Fatalf("stderr = %q, unexpected mismatch on matching token", got) + } +} + func TestCityRuntimeShutdownWaitsForTrackedAsyncDrainAckStopsBeforeStopSnapshot(t *testing.T) { store := beads.NewMemStore() sp := newShutdownWaitStopProvider() @@ -1271,7 +1347,7 @@ func TestCityRuntimeShutdownWaitsForTrackedAsyncDrainAckStopsBeforeStopSnapshot( stdout: ioDiscard{}, stderr: ioDiscard{}, } - queueDrainAckAsyncStop("", store, sp, cr.cfg, "gc-worker", "worker", &cr.asyncStops, &synchronizedBuffer{}) + queueDrainAckAsyncStop("", store, sp, cr.cfg, "gc-worker", "worker", "", &cr.asyncStops, &synchronizedBuffer{}) select { case <-sp.stopStarted: diff --git a/internal/pidutil/pidutil.go b/internal/pidutil/pidutil.go index d4372f5633..00510ab518 100644 --- a/internal/pidutil/pidutil.go +++ b/internal/pidutil/pidutil.go @@ -4,6 +4,7 @@ package pidutil import ( "context" "errors" + "fmt" "os" "os/exec" "path/filepath" @@ -37,6 +38,67 @@ func Alive(pid int) bool { return true } +// StartTime returns a PID's start time — field 22 (starttime, in clock ticks +// since boot) of /proc//stat — as an opaque token used to disambiguate a +// recycled PID from the original target. The kernel never reuses a (pid, +// starttime) pair for the lifetime of a boot, so a changed start time on the +// same PID proves the original process is gone and an unrelated one now holds +// the number. It returns an error on platforms without /proc (e.g. darwin) or +// when the process record is unreadable; callers treat that as "no identity +// signal available" and fall back to plain liveness. +// +// The comm field (field 2) is wrapped in parens and may itself contain spaces +// and parens, so parsing anchors on the final ')' and counts fields from +// there: field 3 (state) is the first token after "') '", making field 22 +// (starttime) the token at index 19 of that suffix. +func StartTime(pid int) (string, error) { + if pid <= 0 { + return "", fmt.Errorf("pidutil: invalid PID %d", pid) + } + data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat")) + if err != nil { + return "", err + } + stat := string(data) + rparen := strings.LastIndexByte(stat, ')') + if rparen < 0 || rparen+2 >= len(stat) { + return "", fmt.Errorf("pidutil: malformed stat for PID %d", pid) + } + fields := strings.Fields(stat[rparen+2:]) + const starttimeIndexAfterComm = 19 // field 22 minus fields 1-3 offset + if len(fields) <= starttimeIndexAfterComm { + return "", fmt.Errorf("pidutil: stat for PID %d has %d post-comm fields, want > %d", pid, len(fields), starttimeIndexAfterComm) + } + return fields[starttimeIndexAfterComm], nil +} + +// AliveWithStartTime reports whether pid is alive AND still the same process +// identified by startTime. It closes the PID-reuse hole in Alive: during a +// post-SIGKILL reap wait the target's PID can be reaped and recycled to an +// unrelated new process inside the window, at which point plain Alive would +// wrongly report the (dead) target as still alive. +// +// An empty startTime disables the identity check and falls back to Alive — used +// on platforms without /proc start-time support (darwin) or when the original +// start time could not be captured before the wait. A non-empty startTime that +// no longer matches means the PID was recycled: the original target is dead, so +// this returns false. When the current start time cannot be read despite Alive +// reporting true (a transient race, no /proc), it keeps the conservative Alive +// answer rather than inventing a death. +func AliveWithStartTime(pid int, startTime string) bool { + if !Alive(pid) { + return false + } + if startTime == "" { + return true + } + current, err := StartTime(pid) + if err != nil { + return true + } + return current == startTime +} + // AliveWithCmdline reports whether a PID exists, is not a zombie, and its // command line satisfies match. On platforms without /proc cmdline support it // falls back to Alive so callers preserve existing non-Linux behavior. diff --git a/internal/pidutil/pidutil_test.go b/internal/pidutil/pidutil_test.go index b8ae5f7b60..26c64d7cf7 100644 --- a/internal/pidutil/pidutil_test.go +++ b/internal/pidutil/pidutil_test.go @@ -48,6 +48,71 @@ func TestPSReportsZombieReturnsWhenPSHangs(t *testing.T) { } } +func TestStartTimeStableForLivePID(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("start-time reads /proc//stat on linux") + } + first, err := StartTime(os.Getpid()) + if err != nil { + t.Fatalf("StartTime(%d): %v", os.Getpid(), err) + } + if first == "" { + t.Fatalf("StartTime(%d) = empty, want a starttime token", os.Getpid()) + } + second, err := StartTime(os.Getpid()) + if err != nil { + t.Fatalf("StartTime(%d) second call: %v", os.Getpid(), err) + } + if first != second { + t.Fatalf("StartTime not stable across calls: %q vs %q", first, second) + } +} + +func TestStartTimeRejectsInvalidPID(t *testing.T) { + if _, err := StartTime(0); err == nil { + t.Fatal("StartTime(0) = nil error, want error") + } +} + +// TestAliveWithStartTimeDisambiguatesRecycledPID checks the three branches that +// close the PID-reuse hole: a matching start time reports alive, a mismatched +// one (the recycled-PID case) reports dead even though the PID is live, and an +// empty start time falls back to plain liveness. +func TestAliveWithStartTimeDisambiguatesRecycledPID(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("start-time identity uses /proc on linux") + } + self := os.Getpid() + st, err := StartTime(self) + if err != nil { + t.Fatalf("StartTime(%d): %v", self, err) + } + + if !AliveWithStartTime(self, st) { + t.Fatalf("AliveWithStartTime(%d, matching) = false, want alive", self) + } + // A different start-time token models the PID having been reaped and reused + // by an unrelated process: the original target must read as dead. + if AliveWithStartTime(self, st+"0") { + t.Fatalf("AliveWithStartTime(%d, mismatched) = true, want dead (recycled)", self) + } + // Empty start time disables the identity check (darwin / uncaptured). + if !AliveWithStartTime(self, "") { + t.Fatalf("AliveWithStartTime(%d, empty) = false, want fallback to Alive", self) + } +} + +func TestAliveWithStartTimeDeadPID(t *testing.T) { + cmd := exec.Command("true") + if err := cmd.Run(); err != nil { + t.Fatalf("spawning test process: %v", err) + } + pid := cmd.ProcessState.Pid() + if AliveWithStartTime(pid, "12345") { + t.Fatalf("AliveWithStartTime(%d, ...) = true for exited process", pid) + } +} + func TestAliveWithCmdlineRejectsUnrelatedLivePID(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("cmdline detection uses /proc on linux") diff --git a/internal/runtime/process_control.go b/internal/runtime/process_control.go index e3e1a6bd0e..21850b678c 100644 --- a/internal/runtime/process_control.go +++ b/internal/runtime/process_control.go @@ -10,6 +10,14 @@ import ( // provider-managed process termination from SIGTERM to SIGKILL. const ManagedProcessStopGrace = 5 * time.Second +// ManagedProcessReapGrace bounds how long a kill waits, after SIGKILL, for the +// target to actually leave the run/ready set before reporting it as +// not-confirmed-dead. A process wedged in uninterruptible sleep (D-state) under +// I/O can outlive its own SIGKILL until the I/O completes; waiting for +// confirmed death (gone or zombie) before starting a replacement is what keeps +// an escaped old process from racing the new one for the same work bead. +const ManagedProcessReapGrace = 3 * time.Second + // SignalProcessGroup sends sig to the managed process group when possible and // falls back to the direct process signal for older sessions or platforms that // cannot signal by group. diff --git a/internal/runtime/proctable/kill_unix.go b/internal/runtime/proctable/kill_unix.go index a17ab2e741..e3f8fd01a3 100644 --- a/internal/runtime/proctable/kill_unix.go +++ b/internal/runtime/proctable/kill_unix.go @@ -8,44 +8,94 @@ import ( "syscall" "time" + "github.com/gastownhall/gascity/internal/pidutil" "github.com/gastownhall/gascity/internal/runtime" ) // KillByPID terminates pid with SIGTERM, then SIGKILL after -// runtime.ManagedProcessStopGrace. Already-gone processes are success. +// runtime.ManagedProcessStopGrace, then waits (bounded by +// runtime.ManagedProcessReapGrace) for the process to be confirmed dead — gone +// or a zombie — before returning. Already-gone processes are success. A process +// that survives its own SIGKILL past the reap grace (e.g. wedged in D-state +// under I/O) yields an error so callers can refuse to start a name-reused +// replacement that would race it for the same work. func KillByPID(pid int) error { + // Capture the target's start-time identity BEFORE signaling. During the + // post-SIGKILL reap wait the PID can be reaped and recycled to an unrelated + // process; without this, a recycled PID reads as "still alive" and we would + // wrongly report a target that is actually gone as not-confirmed-dead, + // spuriously refusing a legitimate Start. StartTime is empty on hosts + // without /proc (darwin) or when the record is unreadable, in which case + // runLive falls back to plain liveness — current behavior preserved. + startTime, _ := pidutil.StartTime(pid) + return killByPID( + pid, + syscall.Kill, + pidAlive, + func(p int) bool { return pidutil.AliveWithStartTime(p, startTime) }, + runtime.ManagedProcessStopGrace, + runtime.ManagedProcessReapGrace, + ) +} + +// killByPID is the signal/confirm core with its syscalls injected so the +// confirmed-dead-before-return contract can be unit-tested without real +// processes. termLive is the cheap kill(0) liveness used during the SIGTERM +// grace window (a zombie still counts as live here, matching prior behavior). +// runLive reports whether the process is still runnable — false once it is gone +// or a zombie, since a zombie can no longer execute and therefore cannot race a +// replacement. +func killByPID( + pid int, + kill func(int, syscall.Signal) error, + termLive func(int) bool, + runLive func(int) bool, + grace, reapGrace time.Duration, +) error { if pid <= 1 { return fmt.Errorf("proctable: refusing to kill PID %d", pid) } - if !pidAlive(pid) { + if !termLive(pid) { return nil } - if err := signalPID(pid, syscall.SIGTERM); err != nil { + if err := signalPIDWith(pid, syscall.SIGTERM, kill); err != nil { return fmt.Errorf("signal PID %d with SIGTERM: %w", pid, err) } - deadline := time.NewTimer(runtime.ManagedProcessStopGrace) + if waitUntil(func() bool { return !termLive(pid) }, grace) { + return nil + } + if err := signalPIDWith(pid, syscall.SIGKILL, kill); err != nil { + return fmt.Errorf("signal PID %d with SIGKILL: %w", pid, err) + } + if waitUntil(func() bool { return !runLive(pid) }, reapGrace) { + return nil + } + return fmt.Errorf("proctable: PID %d still runnable %s after SIGKILL (not confirmed dead)", pid, reapGrace) +} + +// waitUntil polls done at 25ms until it reports true or timeout elapses, +// returning done's final result. Checked once up front so a zero timeout still +// observes an already-satisfied condition. +func waitUntil(done func() bool, timeout time.Duration) bool { + if done() { + return true + } + deadline := time.NewTimer(timeout) defer deadline.Stop() ticker := time.NewTicker(25 * time.Millisecond) defer ticker.Stop() for { select { case <-deadline.C: - if err := signalPID(pid, syscall.SIGKILL); err != nil { - return fmt.Errorf("signal PID %d with SIGKILL: %w", pid, err) - } - return nil + return done() case <-ticker.C: - if !pidAlive(pid) { - return nil + if done() { + return true } } } } -func signalPID(pid int, sig syscall.Signal) error { - return signalPIDWith(pid, sig, syscall.Kill) -} - func signalPIDWith(pid int, sig syscall.Signal, kill func(int, syscall.Signal) error) error { if err := kill(-pid, sig); err == nil { return nil diff --git a/internal/runtime/proctable/kill_unix_test.go b/internal/runtime/proctable/kill_unix_test.go index ac68206898..c7fce08847 100644 --- a/internal/runtime/proctable/kill_unix_test.go +++ b/internal/runtime/proctable/kill_unix_test.go @@ -5,8 +5,10 @@ package proctable import ( "os/exec" "slices" + "strings" "syscall" "testing" + "time" ) func TestKillByPIDRefusesLowPIDs(t *testing.T) { @@ -73,3 +75,77 @@ func TestSignalPIDGroupSuccessSkipsFallback(t *testing.T) { t.Fatalf("signal calls = %v, want %v", got, want) } } + +// TestKillByPIDConfirmedDeadBeforeReturn drives the injected core: a process +// still runnable after SIGKILL (e.g. wedged in D-state) must yield an error so +// a caller can refuse to start a racing replacement, while one that becomes +// dead (gone or zombie) after SIGKILL returns nil. +func TestKillByPIDConfirmedDeadBeforeReturn(t *testing.T) { + t.Run("survives SIGKILL -> error", func(t *testing.T) { + var signals []syscall.Signal + kill := func(_ int, sig syscall.Signal) error { + // Record every delivery attempt. signalPIDWith signals the process + // group (negative pid) first and returns on success, so with this + // always-succeeding fake these are the group deliveries; the + // assertion below only checks the final escalation is SIGKILL. + signals = append(signals, sig) + return nil + } + termLive := func(int) bool { return true } // never exits on SIGTERM + runLive := func(int) bool { return true } // survives SIGKILL too + err := killByPID(4321, kill, termLive, runLive, 5*time.Millisecond, 5*time.Millisecond) + if err == nil { + t.Fatal("killByPID returned nil for a process that survived SIGKILL") + } + if !strings.Contains(err.Error(), "not confirmed dead") { + t.Fatalf("error = %v, want 'not confirmed dead'", err) + } + if len(signals) == 0 || signals[len(signals)-1] != syscall.SIGKILL { + t.Fatalf("signals = %v, want SIGKILL escalation", signals) + } + }) + + t.Run("dies after SIGKILL -> nil", func(t *testing.T) { + kill := func(int, syscall.Signal) error { return nil } + termLive := func(int) bool { return true } // ignores SIGTERM + var kills int + runLive := func(int) bool { + kills++ + return kills <= 1 // alive on first confirm poll, dead after + } + if err := killByPID(4321, kill, termLive, runLive, 5*time.Millisecond, time.Second); err != nil { + t.Fatalf("killByPID: %v", err) + } + }) + + t.Run("exits during SIGTERM grace -> no SIGKILL", func(t *testing.T) { + var sawKill bool + kill := func(_ int, sig syscall.Signal) error { + if sig == syscall.SIGKILL { + sawKill = true + } + return nil + } + var polls int + termLive := func(int) bool { + polls++ + return polls <= 1 // alive at entry, exits before grace elapses + } + runLive := func(int) bool { return false } + if err := killByPID(4321, kill, termLive, runLive, time.Second, time.Second); err != nil { + t.Fatalf("killByPID: %v", err) + } + if sawKill { + t.Fatal("SIGKILL sent even though the process exited during grace") + } + }) +} + +func TestWaitUntilRespectsZeroTimeout(t *testing.T) { + if !waitUntil(func() bool { return true }, 0) { + t.Fatal("waitUntil should observe an already-satisfied condition at zero timeout") + } + if waitUntil(func() bool { return false }, 0) { + t.Fatal("waitUntil should report false when the condition never holds at zero timeout") + } +} diff --git a/internal/runtime/tmux/adapter.go b/internal/runtime/tmux/adapter.go index 8c4dd87523..7f03c04053 100644 --- a/internal/runtime/tmux/adapter.go +++ b/internal/runtime/tmux/adapter.go @@ -324,9 +324,30 @@ func (p *Provider) FindRuntimesBySessionID(id string) ([]runtime.LiveRuntime, er found, scanErr := proctable.ScanBySessionID(id) running, listErr := p.ListRunning("") if listErr != nil { - for i := range found { - found[i].IsTracked = true - } + // Fail CLOSED: without the live-session list we cannot prove which + // scanned roots are gc-tracked. Marking them all tracked (the previous + // behavior) told killExistingOrphans to skip every one, so an escaped + // old process for this exact session survived alongside its + // replacement. Leave IsTracked=false instead: the caller then targets + // the same-session, same-city roots the /proc scan surfaced, and only + // starts once they are confirmed dead. + // + // TRADE-OFF (gascity D1 / MEDIUM-2): when listErr is a *transient* + // tmux-list hiccup rather than a truly-gone server, a still-live + // session's root can land here untracked and be targeted for kill — + // the same tmux machinery backs ensureRunning's !IsRunning gate, so a + // blip flips both. We accept this over the alternative (a survivor + // racing the replacement for the same work bead, causing duplicate bd + // closes), because the survivor bug is silent and corrupts work state + // while a wrongful kill is loud and self-heals on the next reconcile. + // Two mitigations bound the blast radius: (1) KillByPID confirms death + // by PID + /proc start-time identity (pidutil.AliveWithStartTime), so a + // genuinely-live root is never misreported as dead — if it resists the + // kill it surfaces a real "not confirmed dead" error; and (2) that + // error propagates through killExistingOrphans to every gated Start, + // which then refuses rather than racing. Independently re-deriving + // "is this the current live session" here would require the very + // ListRunning that just failed, so it is intentionally not attempted. return found, errors.Join(scanErr, fmt.Errorf("tmux list running: %w", listErr)) } diff --git a/internal/runtime/tmux/tmux.go b/internal/runtime/tmux/tmux.go index 1eb5a2edb3..062106b595 100644 --- a/internal/runtime/tmux/tmux.go +++ b/internal/runtime/tmux/tmux.go @@ -747,28 +747,42 @@ func computeExcludingKillSet(panePID string, descendants, reparented []string, e return killList, !exclude[panePID] } -// collectReparentedGroupMembers returns process group members that have been -// reparented to init (PPID == 1) but are not in the known descendant set. -// These are processes that were likely children in our tree but outlived their -// parent and got reparented to init while keeping the original PGID. -// -// This is safer than killing the entire process group blindly with -// syscall.Kill(-pgid, ...), which could hit unrelated processes if the PGID -// is shared or has been reused after the group leader exited. +// collectReparentedGroupMembers returns process group members that outlived +// their parent inside our tree and were reparented away, but are not already in +// the known descendant set. It shares the pane leader's PGID with every member; +// since the leader is still alive when this runs, the PGID cannot have been +// reused, so members carrying it descend from our tree rather than an unrelated +// process. This is safer than killing the entire group blindly with +// syscall.Kill(-pgid, ...). func collectReparentedGroupMembers(pgid string, knownPIDs map[string]bool) []string { - members := getProcessGroupMembers(pgid) + return reparentedOrphans(getProcessGroupMembers(pgid), knownPIDs, getParentPID) +} + +// reparentedOrphans selects group members whose parent is outside the known +// descendant set — the pure, IO-free core of collectReparentedGroupMembers. +// +// The prior test was literal PPID == 1, which only holds when init adopts the +// orphan. Under a `user@.service` subreaper (systemd --user), an orphaned child +// reparents to the subreaper's pid, not 1, so the PPID == 1 test missed it and +// the tree kill left it alive next to the replacement. "Parent outside the +// descendant set" captures both cases: init (pid 1 is never a descendant) and +// the subreaper (its pid is never a descendant either), while a member whose +// parent is still a live descendant is left to getAllDescendants. Members whose +// parent cannot be read are skipped rather than killed. +func reparentedOrphans(members []string, knownPIDs map[string]bool, parentOf func(string) string) []string { var reparented []string for _, member := range members { if knownPIDs[member] { - continue // Already in descendant list, will be handled there + continue // Already in the descendant list; handled there. + } + ppid := strings.TrimSpace(parentOf(member)) + if ppid == "" { + continue // Parent unknown (raced exit) — cannot prove it's ours. } - // Check if reparented to init — probably was our child - ppid := getParentPID(member) - if ppid == "1" { - reparented = append(reparented, member) + if knownPIDs[ppid] { + continue // Parent still a live descendant; getAllDescendants owns it. } - // Otherwise skip — this process is not in our tree and not reparented, - // so it's likely unrelated and should not be killed + reparented = append(reparented, member) } return reparented } diff --git a/internal/runtime/tmux/tmux_test.go b/internal/runtime/tmux/tmux_test.go index 47c507ddc2..d837bd5a8c 100644 --- a/internal/runtime/tmux/tmux_test.go +++ b/internal/runtime/tmux/tmux_test.go @@ -1258,8 +1258,12 @@ func TestCleanupOrphanedSessions_NoSessions(t *testing.T) { func TestCollectReparentedGroupMembers(t *testing.T) { // Test that collectReparentedGroupMembers correctly filters group members. - // Only processes reparented to init (PPID == 1) that aren't in the known set - // should be returned. + // A returned member must not be in the known set and must have a parent + // outside the known descendant set (parents that reparented to init OR to a + // user-session subreaper both qualify). The full parent-outside-set rule is + // covered deterministically with an injected parentOf by + // TestReparentedOrphans_* in tmux_unit_test.go; this test exercises the real + // getProcessGroupID/getParentPID integration. // Test with current process's PGID pid := fmt.Sprintf("%d", os.Getpid()) @@ -1277,17 +1281,18 @@ func TestCollectReparentedGroupMembers(t *testing.T) { if rpid == pid { t.Errorf("collectReparentedGroupMembers returned known PID %s", pid) } - // Each reparented PID should have PPID == 1. - // The process may have exited between collection and this check - // (TOCTOU race), so skip verification if getParentPID returns empty. + // A returned member's parent must be outside the known set (the + // "parent outside the known descendant set" rule). The process may + // exit between collection and this check (TOCTOU race), so skip + // verification if getParentPID returns empty for a since-exited PID. ppid := getParentPID(rpid) if ppid == "" && runtime.GOOS != "windows" { if err := exec.Command("kill", "-0", rpid).Run(); err != nil { continue } } - if ppid != "1" { - t.Errorf("collectReparentedGroupMembers returned PID %s with PPID %s (expected 1)", rpid, ppid) + if knownPIDs[ppid] { + t.Errorf("collectReparentedGroupMembers returned PID %s whose parent %s is in the known set", rpid, ppid) } } } diff --git a/internal/runtime/tmux/tmux_unit_test.go b/internal/runtime/tmux/tmux_unit_test.go index cbc17acd74..9d29227408 100644 --- a/internal/runtime/tmux/tmux_unit_test.go +++ b/internal/runtime/tmux/tmux_unit_test.go @@ -84,3 +84,44 @@ func TestComputeExcludingKillSet_ExcludedPaneLeaderSurvives(t *testing.T) { t.Error("an excluded pane leader must not be killed directly") } } + +// knownSet builds a descendant-set lookup from the given pids. +func knownSet(pids ...string) map[string]bool { + m := make(map[string]bool, len(pids)) + for _, p := range pids { + m[p] = true + } + return m +} + +func TestReparentedOrphans_CollectsInitAndSubreaperOrphans(t *testing.T) { + // leader=100, one live descendant=200. Group also holds: + // 300 reparented to init (ppid 1) — classic case + // 400 reparented to systemd --user subreaper (ppid 900) — the case the + // old PPID==1 test missed + // 500 still a child of a live descendant (ppid 200) — owned elsewhere + // 600 whose parent read failed ("") — must be skipped + known := knownSet("100", "200") + parents := map[string]string{ + "300": "1", + "400": "900", // systemd --user pid, not init + "500": "200", + "600": "", + } + parentOf := func(pid string) string { return parents[pid] } + + got := reparentedOrphans([]string{"200", "300", "400", "500", "600"}, known, parentOf) + slices.Sort(got) + want := []string{"300", "400"} + if !slices.Equal(got, want) { + t.Fatalf("reparentedOrphans = %v, want %v", got, want) + } +} + +func TestReparentedOrphans_SkipsKnownDescendants(t *testing.T) { + known := knownSet("100", "200", "300") + parentOf := func(string) string { return "1" } + if got := reparentedOrphans([]string{"200", "300"}, known, parentOf); len(got) != 0 { + t.Fatalf("reparentedOrphans = %v, want empty (all are known descendants)", got) + } +} diff --git a/internal/session/chat.go b/internal/session/chat.go index 41ce8d9159..e24377aead 100644 --- a/internal/session/chat.go +++ b/internal/session/chat.go @@ -202,7 +202,16 @@ func (m *Manager) retryFreshStartAfterStaleKey( } } cfg.Command = freshCmd - m.killExistingOrphans(ctx, id) + // Refuse the fresh start if a prior escaped process for this session could + // not be confirmed dead: a survivor would race this replacement for the + // same work bead. This path reuses the existing bead ID, so there is no + // fresh-create to roll back — unroute and propagate the error before Start. + if orphanErr := m.killExistingOrphans(ctx, id); orphanErr != nil { + if unroute != nil { + unroute() + } + return false, fmt.Errorf("pre-start orphan cleanup: %w", orphanErr) + } if err := m.sp.Start(ctx, sessName, cfg); err != nil { if unroute != nil { unroute() @@ -371,7 +380,17 @@ func (m *Manager) ensureRunning(ctx context.Context, id string, b beads.Bead, se } cfg = runtime.SyncWorkDirEnv(cfg) started := false - m.killExistingOrphans(ctx, id) + // Refuse to resume if a prior escaped process for this session could not be + // confirmed dead: a survivor would race this replacement for the same work + // bead (duplicate bd close). This is the stable/reused-bead-ID path — the + // exact "old process survives alongside its replacement" scenario. No + // fresh-create to roll back, so unroute and propagate before Start. + if orphanErr := m.killExistingOrphans(ctx, id); orphanErr != nil { + if unroute != nil { + unroute() + } + return fmt.Errorf("pre-start orphan cleanup: %w", orphanErr) + } if err := m.sp.Start(ctx, sessName, cfg); err != nil { if errors.Is(err, runtime.ErrSessionDiedDuringStartup) && b.Metadata["session_key"] != "" { retried, err := m.retryFreshStartAfterStaleKey(ctx, id, &b, sessName, resumeCommand, cfg, unroute) @@ -482,7 +501,16 @@ func (m *Manager) ensureRunningRuntimeOnly(ctx context.Context, id string, b bea } cfg = runtime.SyncWorkDirEnv(cfg) started := false - m.killExistingOrphans(ctx, id) + // Refuse to respawn if a prior escaped process for this session could not + // be confirmed dead: a survivor would race this replacement for the same + // work bead. This is the reconciler respawn bridge on a stable/reused bead + // ID. No fresh-create to roll back, so unroute and propagate before Start. + if orphanErr := m.killExistingOrphans(ctx, id); orphanErr != nil { + if unroute != nil { + unroute() + } + return fmt.Errorf("pre-start orphan cleanup: %w", orphanErr) + } if err := m.sp.Start(ctx, sessName, cfg); err != nil { switch { case errors.Is(err, runtime.ErrSessionDiedDuringStartup) && b.Metadata["session_key"] != "": diff --git a/internal/session/manager.go b/internal/session/manager.go index 4135ba1123..f3060a9b68 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -511,17 +511,25 @@ func (m *Manager) persistTransport(id, provider, transport string) { _ = m.store.SetMetadata(id, "transport", transport) } -func (m *Manager) killExistingOrphans(ctx context.Context, sessionID string) { +// killExistingOrphans terminates any untracked runtime whose session ID and +// city match the session about to start, then confirms each is dead. It returns +// a non-nil error only when an orphan could not be confirmed dead, so callers +// gating a Start can refuse rather than race a survivor for the same work. A +// scan error is logged and treated as fail-closed (see FindRuntimesBySessionID): +// the roots the scan did surface are still killed, and matching the started +// replacement is impossible because it does not exist yet. +func (m *Manager) killExistingOrphans(ctx context.Context, sessionID string) error { _ = ctx scanner, ok := m.sp.(runtime.ProcessTableScanner) if !ok || sessionID == "" { - return + return nil } found, err := scanner.FindRuntimesBySessionID(sessionID) if err != nil { - log.Printf("session: scanning for orphaned runtimes for %s: %v", sessionID, err) + log.Printf("session: scanning for orphaned runtimes for %s (failing closed): %v", sessionID, err) } cityPath := pathutil.NormalizePathForCompare(strings.TrimSpace(m.cityPath)) + var termErrs []error for _, live := range found { if live.IsTracked || live.SessionID != sessionID { continue @@ -531,8 +539,13 @@ func (m *Manager) killExistingOrphans(ctx context.Context, sessionID string) { } if err := scanner.TerminateRuntime(live); err != nil { log.Printf("session: terminating orphaned runtime for %s pid=%d provider_name=%q: %v", sessionID, live.PID, live.ProviderName, err) + termErrs = append(termErrs, fmt.Errorf("orphan pid=%d provider_name=%q: %w", live.PID, live.ProviderName, err)) } } + if len(termErrs) > 0 { + return fmt.Errorf("%d orphaned runtime(s) not confirmed dead: %w", len(termErrs), errors.Join(termErrs...)) + } + return nil } func (m *Manager) now() time.Time { @@ -815,8 +828,15 @@ func (m *Manager) createAliasedNamedWithTransport(ctx context.Context, alias, ex } cfg = runtime.SyncWorkDirEnv(cfg) - // Start the runtime session. - m.killExistingOrphans(ctx, b.ID) + // Start the runtime session. Refuse to start if a prior escaped process + // for this session could not be confirmed dead: a survivor would race + // the replacement for the same work bead (duplicate bd close). + if orphanErr := m.killExistingOrphans(ctx, b.ID); orphanErr != nil { + if rbErr := rollbackFailedCreate(); rbErr != nil { + return errors.Join(fmt.Errorf("pre-start orphan cleanup: %w", orphanErr), rbErr) + } + return fmt.Errorf("pre-start orphan cleanup: %w", orphanErr) + } if err := m.sp.Start(ctx, sessName, cfg); err != nil { if runtimeSessionMatchesBead(m.sp, sessName, b.ID, meta["instance_token"]) { if metaErr := m.confirmStartedRuntimeMetadata(b.ID, &b); metaErr != nil { diff --git a/internal/session/manager_test.go b/internal/session/manager_test.go index 78fa52060c..b19ddde04d 100644 --- a/internal/session/manager_test.go +++ b/internal/session/manager_test.go @@ -405,7 +405,13 @@ func TestCreateKillsUntrackedOrphanFromSameCityBeforeStartWithNormalizedPath(t * } } -func TestCreateContinuesWhenOrphanCleanupFails(t *testing.T) { +// TestCreateRefusesStartWhenOrphanNotConfirmedDead pins the fail-closed +// contract: when an untracked same-session orphan cannot be confirmed dead +// (TerminateRuntime errors — e.g. it survived SIGKILL), Create must refuse to +// start a replacement rather than race the survivor for the same work bead. A +// concurrent scan error is logged and treated as fail-closed, so the orphan the +// scan did surface is still targeted. No Start is attempted. +func TestCreateRefusesStartWhenOrphanNotConfirmedDead(t *testing.T) { store := beads.NewMemStore() sp := &orphanScanProvider{ Fake: runtime.NewFake(), @@ -418,17 +424,225 @@ func TestCreateContinuesWhenOrphanCleanupFails(t *testing.T) { } mgr := NewManager(store, sp) - info, err := mgr.Create(context.Background(), "helper", "my chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + _, err := mgr.Create(context.Background(), "helper", "my chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + if err == nil { + t.Fatal("Create succeeded despite an orphan that could not be confirmed dead") + } + if !strings.Contains(err.Error(), "orphan cleanup") { + t.Fatalf("Create error = %v, want pre-start orphan cleanup refusal", err) + } + for _, e := range sp.events { + if strings.HasPrefix(e, "start:") { + t.Fatalf("Start was attempted despite unconfirmed orphan; events = %v", sp.events) + } + } + want := []string{"find:", "terminate:"} + for i, prefix := range want { + if i >= len(sp.events) || !strings.HasPrefix(sp.events[i], prefix) { + t.Fatalf("events = %v, want prefixes %v", sp.events, want) + } + } +} + +// acpOrphanScanProvider augments orphanScanProvider with ACP route bookkeeping +// so a resume that reserves an ACP route before the pre-start orphan gate can +// be observed unwinding that reservation when the gate refuses. RouteACP and +// Unroute record into the same events slice as the scan/start calls. +type acpOrphanScanProvider struct { + *orphanScanProvider +} + +func (p *acpOrphanScanProvider) RouteACP(name string) { p.events = append(p.events, "route:"+name) } +func (p *acpOrphanScanProvider) Unroute(name string) { p.events = append(p.events, "unroute:"+name) } + +// seedSuspendedResumeTarget creates a session backed by a clean orphan scanner +// and suspends it, so a subsequent Manager.Start/StartRuntimeOnly takes the +// resume path (stopped runtime, non-empty resume command) and reaches the +// pre-start orphan gate. It clears the recorded events after suspend so callers +// observe only the resume attempt, and returns the provider ready to be armed +// with an orphan. +func seedSuspendedResumeTarget(t *testing.T) (*Manager, *orphanScanProvider, Info) { + t.Helper() + store := beads.NewMemStore() + sp := &orphanScanProvider{Fake: runtime.NewFake()} + mgr := NewManager(store, sp) + info, err := mgr.Create(context.Background(), "helper", "", "claude", t.TempDir(), "claude", nil, ProviderResume{}, runtime.Config{}) if err != nil { t.Fatalf("Create: %v", err) } + if err := mgr.Suspend(info.ID); err != nil { + t.Fatalf("Suspend: %v", err) + } + sp.events = nil + return mgr, sp, info +} + +func hasEventPrefix(events []string, prefix string) bool { + for _, e := range events { + if strings.HasPrefix(e, prefix) { + return true + } + } + return false +} + +// armUnconfirmedOrphan makes killExistingOrphans surface a same-session +// untracked orphan whose termination fails, i.e. one that cannot be confirmed +// dead. This is the fixture shape TestCreateRefusesStartWhenOrphanNotConfirmedDead +// uses for the Create path. +func armUnconfirmedOrphan(sp *orphanScanProvider) { + sp.results = []runtime.LiveRuntime{{PID: 1234, IsTracked: false}} + sp.terminateErr = errors.New("terminate failed") +} + +// armConfirmedDeadOrphan makes killExistingOrphans surface a same-session +// untracked orphan that terminates cleanly (confirmed dead), so the gate lets +// the Start proceed. +func armConfirmedDeadOrphan(sp *orphanScanProvider) { + sp.results = []runtime.LiveRuntime{{PID: 1234, IsTracked: false}} + sp.terminateErr = nil +} + +// TestStartRefusesResumeWhenOrphanNotConfirmedDead drives the real +// Manager.Start -> ensureRunning path (chat.go ~388) with the same +// not-confirmed-dead orphan fixture the Create behavioral test uses, and pins +// the runtime behavior of the fix: Start returns a pre-start orphan cleanup +// error and no replacement runtime is started (sp.Start is never called). A +// regression that swallowed the gate error (e.g. `if orphanErr != nil { /* +// no-op */ }`) would pass errcheck and the structural scan but fail here. +func TestStartRefusesResumeWhenOrphanNotConfirmedDead(t *testing.T) { + mgr, sp, info := seedSuspendedResumeTarget(t) + armUnconfirmedOrphan(sp) + + err := mgr.Start(context.Background(), info.ID, BuildResumeCommand(info), runtime.Config{WorkDir: info.WorkDir}) + if err == nil { + t.Fatal("Start succeeded despite an orphan that could not be confirmed dead") + } + if !strings.Contains(err.Error(), "orphan cleanup") { + t.Fatalf("Start error = %v, want pre-start orphan cleanup refusal", err) + } + if hasEventPrefix(sp.events, "start:") { + t.Fatalf("Start was attempted despite unconfirmed orphan; events = %v", sp.events) + } + want := []string{"find:", "terminate:"} + for i, prefix := range want { + if i >= len(sp.events) || !strings.HasPrefix(sp.events[i], prefix) { + t.Fatalf("events = %v, want prefixes %v", sp.events, want) + } + } +} + +// TestStartRuntimeOnlyRefusesRespawnWhenOrphanNotConfirmedDead is the +// StartRuntimeOnly (reconciler respawn bridge, chat.go ~508) counterpart of +// TestStartRefusesResumeWhenOrphanNotConfirmedDead. +func TestStartRuntimeOnlyRefusesRespawnWhenOrphanNotConfirmedDead(t *testing.T) { + mgr, sp, info := seedSuspendedResumeTarget(t) + armUnconfirmedOrphan(sp) + + err := mgr.StartRuntimeOnly(context.Background(), info.ID, BuildResumeCommand(info), runtime.Config{WorkDir: info.WorkDir}) + if err == nil { + t.Fatal("StartRuntimeOnly succeeded despite an orphan that could not be confirmed dead") + } + if !strings.Contains(err.Error(), "orphan cleanup") { + t.Fatalf("StartRuntimeOnly error = %v, want pre-start orphan cleanup refusal", err) + } + if hasEventPrefix(sp.events, "start:") { + t.Fatalf("Start was attempted despite unconfirmed orphan; events = %v", sp.events) + } + want := []string{"find:", "terminate:"} + for i, prefix := range want { + if i >= len(sp.events) || !strings.HasPrefix(sp.events[i], prefix) { + t.Fatalf("events = %v, want prefixes %v", sp.events, want) + } + } +} + +// TestStartProceedsWhenOrphanConfirmedDead is the positive counterpart: when +// the same-session orphan IS confirmed dead, Manager.Start proceeds and starts +// the replacement runtime. It proves the gate does not over-refuse. +func TestStartProceedsWhenOrphanConfirmedDead(t *testing.T) { + mgr, sp, info := seedSuspendedResumeTarget(t) + armConfirmedDeadOrphan(sp) + + if err := mgr.Start(context.Background(), info.ID, BuildResumeCommand(info), runtime.Config{WorkDir: info.WorkDir}); err != nil { + t.Fatalf("Start: %v", err) + } + want := []string{"find:" + info.ID, "terminate:" + info.ID, "start:" + info.ID} + if got := strings.Join(sp.events, ","); got != strings.Join(want, ",") { + t.Fatalf("events = %v, want %v", sp.events, want) + } if !sp.IsRunning(info.SessionName) { - t.Fatalf("runtime session %q was not started after cleanup errors", info.SessionName) + t.Fatalf("runtime session %q not running after resume", info.SessionName) + } +} + +// TestStartRuntimeOnlyProceedsWhenOrphanConfirmedDead is the StartRuntimeOnly +// positive counterpart. +func TestStartRuntimeOnlyProceedsWhenOrphanConfirmedDead(t *testing.T) { + mgr, sp, info := seedSuspendedResumeTarget(t) + armConfirmedDeadOrphan(sp) + + if err := mgr.StartRuntimeOnly(context.Background(), info.ID, BuildResumeCommand(info), runtime.Config{WorkDir: info.WorkDir}); err != nil { + t.Fatalf("StartRuntimeOnly: %v", err) } want := []string{"find:" + info.ID, "terminate:" + info.ID, "start:" + info.ID} if got := strings.Join(sp.events, ","); got != strings.Join(want, ",") { t.Fatalf("events = %v, want %v", sp.events, want) } + if !sp.IsRunning(info.SessionName) { + t.Fatalf("runtime session %q not running after respawn", info.SessionName) + } +} + +// TestStartUnwindsACPRouteWhenOrphanNotConfirmedDead pins the route-unwinding +// half of the fix: when the resume path reserved an ACP route before the +// pre-start orphan gate, a refusal must call unroute() so the reservation is +// released rather than leaked. It seeds an ACP-transport session bead directly +// (mirroring the legacy-ACP fixtures elsewhere in this file) so ensureRunning +// reserves a route via RouteACP, then arms a not-confirmed-dead orphan and +// asserts Unroute fires and no runtime Start is attempted. +func TestStartUnwindsACPRouteWhenOrphanNotConfirmedDead(t *testing.T) { + store := beads.NewMemStore() + sp := &acpOrphanScanProvider{orphanScanProvider: &orphanScanProvider{Fake: runtime.NewFake()}} + armUnconfirmedOrphan(sp.orphanScanProvider) + mgr := NewManager(store, sp) + + b, err := store.Create(beads.Bead{ + Type: BeadType, + Labels: []string{LabelSession}, + Metadata: map[string]string{ + "state": string(StateSuspended), + "provider": "claude", + "transport": "acp", + "work_dir": "/tmp", + "command": "claude", + }, + }) + if err != nil { + t.Fatalf("Create bead: %v", err) + } + sessName := sessionNameFor(b.ID) + if err := store.SetMetadata(b.ID, "session_name", sessName); err != nil { + t.Fatalf("SetMetadata(session_name): %v", err) + } + sp.events = nil + + err = mgr.Start(context.Background(), b.ID, "claude", runtime.Config{WorkDir: "/tmp"}) + if err == nil { + t.Fatal("Start succeeded despite an orphan that could not be confirmed dead") + } + if !strings.Contains(err.Error(), "orphan cleanup") { + t.Fatalf("Start error = %v, want pre-start orphan cleanup refusal", err) + } + if hasEventPrefix(sp.events, "start:") { + t.Fatalf("Start was attempted despite unconfirmed orphan; events = %v", sp.events) + } + if !hasEventPrefix(sp.events, "route:") { + t.Fatalf("expected an ACP route reservation before the gate; events = %v", sp.events) + } + if !hasEventPrefix(sp.events, "unroute:") { + t.Fatalf("ACP route reservation was not unwound on refusal; events = %v", sp.events) + } } func TestCreateWithProviderWithoutProcessScannerStillStarts(t *testing.T) { @@ -466,9 +680,12 @@ func TestRuntimeStartCallSitesCleanOrphansFirst(t *testing.T) { continue } starts++ - prev := previousNonBlankLine(lines, i) - if !strings.Contains(prev, "m.killExistingOrphans(ctx, "+tt.idExpr+")") { - t.Errorf("%s:%d Start is not immediately preceded by orphan cleanup using %s; previous line: %q", tt.file, i+1, tt.idExpr, prev) + // The cleanup call may sit a few lines above the Start when its + // result gates the Start (manager.go wraps it in an + // `if orphanErr := …; orphanErr != nil` refusal), so scan a + // short preceding window rather than only the immediate line. + if !orphanCleanupPrecedes(lines, i, tt.idExpr) { + t.Errorf("%s:%d Start is not preceded by orphan cleanup using %s", tt.file, i+1, tt.idExpr) } } if starts == 0 { @@ -478,13 +695,25 @@ func TestRuntimeStartCallSitesCleanOrphansFirst(t *testing.T) { } } -func previousNonBlankLine(lines []string, before int) string { - for i := before - 1; i >= 0; i-- { - if strings.TrimSpace(lines[i]) != "" { - return strings.TrimSpace(lines[i]) +// orphanCleanupPrecedes reports whether m.killExistingOrphans(ctx, idExpr) +// appears within the short window of non-blank lines preceding the Start at +// index before. The window keeps the "every Start is guarded by orphan +// cleanup" invariant while tolerating the gate wrapper that consumes the +// cleanup's error. +func orphanCleanupPrecedes(lines []string, before int, idExpr string) bool { + needle := "m.killExistingOrphans(ctx, " + idExpr + ")" + const window = 10 + seen := 0 + for i := before - 1; i >= 0 && seen < window; i-- { + if strings.TrimSpace(lines[i]) == "" { + continue + } + seen++ + if strings.Contains(lines[i], needle) { + return true } } - return "" + return false } func TestUpdateTemplateOverridesRejectsRunningSessionUnderLock(t *testing.T) { diff --git a/internal/workspacesvc/orphan_reap.go b/internal/workspacesvc/orphan_reap.go index 0362f34266..e4fe83f378 100644 --- a/internal/workspacesvc/orphan_reap.go +++ b/internal/workspacesvc/orphan_reap.go @@ -26,7 +26,9 @@ import ( // (see orphanIdentity.matchesLive): // // 1. it is alive and not a zombie; -// 2. it has re-parented to init (ppid 1), so no live supervisor owns it; +// 2. it has re-parented to a subreaper — init (ppid 1), or the detected +// `systemd --user` manager under a user@.service — so no live supervisor +// owns it (see orphanIdentity.parentIsSubreaper); // 3. its command line is exactly the service's configured command; and // 4. its environment carries GC_SERVICE_NAME= and // GC_SERVICE_STATE_ROOT=, proving a gc @@ -55,6 +57,12 @@ type orphanIdentity struct { serviceName string stateRoot string command []string + // subreaperPID is the pid of the `systemd --user` subreaper that adopts + // this user session's orphans, or 0 when there is none (plain init host / + // container). It is set at sweep time by reapOrphanedServiceProcesses; + // newOrphanIdentity leaves it 0 so the identity keeps the strict + // re-parented-to-init (ppid 1) rule until a subreaper is detected. + subreaperPID int } // newOrphanIdentity builds the sweep identity for one service instance. @@ -84,7 +92,8 @@ func (id orphanIdentity) matchesLive(pid int) bool { if !pidutil.Alive(pid) { return false } - if ppid, err := processParentPID(pid); err != nil || ppid != 1 { + ppid, err := processParentPID(pid) + if err != nil || !id.parentIsSubreaper(ppid) { return false } if !processCmdlineEquals(pid, id.command) { @@ -93,11 +102,31 @@ func (id orphanIdentity) matchesLive(pid int) bool { return processEnvironMatchesService(pid, id.serviceName, id.stateRoot) } +// parentIsSubreaper reports whether ppid is a subreaper that would own this +// process only if the supervisor that spawned it has already exited: init +// (pid 1) on hosts without a user subreaper, or the detected `systemd --user` +// manager under a user@.service. A live gc supervisor is never a subreaper, so +// a still-owned service child — whose ppid is its live supervisor's pid — +// never matches, and neither does the sweeper's own supervisor process (it +// fails the command/environ checks regardless). Rule 2 of the file header +// ("no live supervisor owns it") thus holds under both the plain-init and +// systemd --user reparenting models. +func (id orphanIdentity) parentIsSubreaper(ppid int) bool { + if ppid == 1 { + return true + } + return id.subreaperPID > 1 && ppid == id.subreaperPID +} + // reapOrphanedServiceProcesses terminates orphaned survivors of previous // hard exits that match the service instance's identity. Best-effort: scan // or signal failures are logged and never block the spawn; on hosts without // /proc the sweep is a no-op. func reapOrphanedServiceProcesses(id orphanIdentity) { + // The sweeper runs under the same subreaper that adopts this supervisor's + // orphans, so detect it from the sweeper's own ancestry. On a plain-init + // host this stays 0 and matchesLive keeps the strict ppid==1 rule. + id.subreaperPID = detectUserSubreaperPID(os.Getpid()) pids := findOrphanedServiceProcesses(id) if len(pids) == 0 { return @@ -106,6 +135,44 @@ func reapOrphanedServiceProcesses(id orphanIdentity) { terminateOrphanedProcesses(id, pids) } +// detectUserSubreaperPID returns the pid of the `systemd --user` manager that +// acts as the child subreaper for this user session, or 0 if there is none. +// +// Under a user@UID.service, systemd --user sets PR_SET_CHILD_SUBREAPER, so any +// orphan in the session reparents to it (not to pid 1). The sweeper is itself a +// descendant of that manager, so we walk the sweeper's parent chain and return +// the nearest ancestor named "systemd" whose pid is not 1 — i.e. the user +// manager, distinct from the system systemd at pid 1. The walk is bounded to +// guard against malformed /proc data and stops at pid 1. +func detectUserSubreaperPID(self int) int { + return detectUserSubreaperPIDWith(self, processParentPID, processComm) +} + +func detectUserSubreaperPIDWith(self int, parentOf func(int) (int, error), commOf func(int) string) int { + pid := self + for depth := 0; depth < 64; depth++ { + ppid, err := parentOf(pid) + if err != nil || ppid <= 1 { + return 0 + } + if commOf(ppid) == "systemd" { + return ppid + } + pid = ppid + } + return 0 +} + +// processComm returns the executable name from /proc//comm, or "" if it +// cannot be read. +func processComm(pid int) string { + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + // findOrphanedServiceProcesses scans /proc for processes matching id. // Processes that exit mid-scan or whose records are unreadable are skipped. func findOrphanedServiceProcesses(id orphanIdentity) []int { diff --git a/internal/workspacesvc/orphan_reap_test.go b/internal/workspacesvc/orphan_reap_test.go index 5206a539bb..7941c95582 100644 --- a/internal/workspacesvc/orphan_reap_test.go +++ b/internal/workspacesvc/orphan_reap_test.go @@ -387,3 +387,68 @@ func TestProxyProcessStartReapsOrphanedDuplicates(t *testing.T) { t.Fatalf("LocalState = %q, want ready (reason=%q)", status.LocalState, status.Reason) } } + +func TestParentIsSubreaper(t *testing.T) { + tests := []struct { + name string + subreaperPID int + ppid int + want bool + }{ + {name: "init always counts", subreaperPID: 0, ppid: 1, want: true}, + {name: "init counts even with subreaper set", subreaperPID: 900, ppid: 1, want: true}, + {name: "systemd --user subreaper counts", subreaperPID: 900, ppid: 900, want: true}, + {name: "live supervisor pid does not count", subreaperPID: 900, ppid: 1234, want: false}, + {name: "no subreaper detected -> only init", subreaperPID: 0, ppid: 900, want: false}, + {name: "subreaper pid 1 is ignored as a subreaper key", subreaperPID: 1, ppid: 1234, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id := orphanIdentity{subreaperPID: tt.subreaperPID} + if got := id.parentIsSubreaper(tt.ppid); got != tt.want { + t.Fatalf("parentIsSubreaper(%d) with subreaperPID=%d = %v, want %v", tt.ppid, tt.subreaperPID, got, tt.want) + } + }) + } +} + +func TestDetectUserSubreaperPID(t *testing.T) { + // Ancestry: self(500) -> shell(400) -> systemd --user(900) -> systemd(1). + t.Run("finds systemd --user manager", func(t *testing.T) { + parents := map[int]int{500: 400, 400: 900, 900: 1} + comms := map[int]string{400: "bash", 900: "systemd", 1: "systemd"} + parentOf := func(pid int) (int, error) { return parents[pid], nil } + commOf := func(pid int) string { return comms[pid] } + if got := detectUserSubreaperPIDWith(500, parentOf, commOf); got != 900 { + t.Fatalf("detectUserSubreaperPID = %d, want 900", got) + } + }) + + t.Run("plain init host returns 0", func(t *testing.T) { + // self(500) -> supervisor(400) -> init(1); only systemd is pid 1. + parents := map[int]int{500: 400, 400: 1} + comms := map[int]string{400: "gc", 1: "systemd"} + parentOf := func(pid int) (int, error) { return parents[pid], nil } + commOf := func(pid int) string { return comms[pid] } + if got := detectUserSubreaperPIDWith(500, parentOf, commOf); got != 0 { + t.Fatalf("detectUserSubreaperPID = %d, want 0 (no user subreaper)", got) + } + }) + + t.Run("unreadable parent returns 0", func(t *testing.T) { + parentOf := func(int) (int, error) { return 0, fmt.Errorf("no /proc") } + commOf := func(int) string { return "" } + if got := detectUserSubreaperPIDWith(500, parentOf, commOf); got != 0 { + t.Fatalf("detectUserSubreaperPID = %d, want 0", got) + } + }) + + t.Run("cyclic ancestry terminates", func(t *testing.T) { + // Malformed /proc reporting a cycle must not loop forever. + parentOf := func(int) (int, error) { return 700, nil } + commOf := func(int) string { return "notsystemd" } + if got := detectUserSubreaperPIDWith(700, parentOf, commOf); got != 0 { + t.Fatalf("detectUserSubreaperPID = %d, want 0", got) + } + }) +} From 53de0c71194115acd88d89bc17f85bc2cdd13464 Mon Sep 17 00:00:00 2001 From: Stephanie Jarmak <36544495+sjarmak@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:56:13 -0400 Subject: [PATCH 009/225] feat(runtime): emit PartialListError from tmux ListRunning to activate reconciler guards (#4087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Follow-up to #4082, which shielded the `StateCache` liveness path but left the reconciler-facing `ListRunning` sites reading a full tmux outage as "zero sessions." The repo already has the convention to prevent that (`PartialListError` / `IsPartialListError` in `internal/runtime/provider_core.go`) and four reconciler sites already guard on it, but nothing emitted the signal for a single-tmux total outage, so the guards were dark. ## What changed - `internal/runtime/tmux/adapter.go`: `Provider.ListRunning` reports a totally unreachable tmux server (`ErrNoServer`) as a `runtime.PartialListError` (nil names) instead of the old empty success `(nil, nil)`, activating the existing guards with no new plumbing. - `internal/runtime/tmux/tmux.go`: the raw `list-sessions` call moves to a private `listSessionNames()` that propagates `ErrNoServer`; `ListSessions()` stays a thin wrapper that re-absorbs `ErrNoServer → (nil, nil)` for its two tmux-internal callers (`FindSessionByWorkDir`, `CleanupOrphanedSessions`), whose behavior is unchanged. Emitting at the provider layer (not `ListSessions`) keeps the blast radius on the reconciler-facing path only. - `engdocs/design/runtime-partial-discipline.md`: moves the `ListRunning` sites from "still exposed" to landed. Two sites gain a genuine safety fix: the pool `on_death` hooks (`city_runtime.go:960`) no longer fire the user's on_death command for every slot on a blip (the false death storm), and the config-reload provider swap (`:1899`) no longer proceeds with zero visible sessions. The shutdown sites (`:3466`/`:3478`) were already a no-op stop set under the old path; only their diagnostic message changes. ## Blast-radius audit All ~20 `ListRunning` callers were checked. None treats the new non-nil error as a trigger for destructive action: each either already guards on `IsPartialListError` and defers, degrades to the StateCache-backed per-session `IsRunning` (protected by #4082), or returns the same empty/nil result it produced under the old absorbed-error path. The auto/hybrid composites fold the signal through `MergeBackendListResults`; both `IsPartialListError` and `errors.Is(err, ErrNoServer)` still resolve through the nesting. ## Test plan - `go build ./...`, `go vet ./internal/runtime/... ./cmd/gc/...`: clean - `go test ./internal/runtime/...`: pass - New `internal/runtime/tmux/adapter_unit_test.go`: provider emits `PartialListError` on no-server; a genuine tmux failure is not misclassified as partial; `ListSessions` still absorbs for internal callers. The pre-existing destructive-site guard tests (on_death, provider-swap) now exercise a reachable production path. --------- Co-authored-by: sjarmak --- engdocs/design/runtime-partial-discipline.md | 66 +++++++++++++------- internal/runtime/tmux/adapter.go | 15 ++++- internal/runtime/tmux/adapter_unit_test.go | 54 ++++++++++++++++ internal/runtime/tmux/tmux.go | 31 ++++++--- 4 files changed, 136 insertions(+), 30 deletions(-) diff --git a/engdocs/design/runtime-partial-discipline.md b/engdocs/design/runtime-partial-discipline.md index 9e9ca678fc..7b8689a71d 100644 --- a/engdocs/design/runtime-partial-discipline.md +++ b/engdocs/design/runtime-partial-discipline.md @@ -29,26 +29,45 @@ exist", so a brief blip drove the reconciler to drain/close healthy pool slots. `staleTTL` (30s default). The wrapped error still satisfies `isNoServerError`, so the ~20 existing `ErrNoServer` absorbers are unaffected. -### Still exposed: the `ListRunning` sites +### Landed (arm 6): the `ListRunning` sites -The `FetchState` fix shields the `StateCache.IsRunning` liveness path, but NOT -`Provider.ListRunning` (via `Tmux.ListSessions`), which still returns -`(nil, nil)` on `ErrNoServer` — an empty *success*, not a partial signal. Three -reconciler-facing sites call `ListRunning` destructively on that empty result: +`Provider.ListRunning` (`internal/runtime/tmux/adapter.go`) now reports a totally +unreachable tmux server (`ErrNoServer`) as a `runtime.PartialListError` with a +nil names slice, instead of the old empty *success* (`(nil, nil)`). This +activates the `IsPartialListError` guards that already exist at every +reconciler-facing site, with no new plumbing: + +Two sites had a genuine destructive-behavior change: + +- `cmd/gc/city_runtime.go:960` — pool `on_death` hooks. Previously a full tmux + outage made every pool slot vanish from the empty listing at once, firing the + user's `on_death` command for EVERY slot (a false death storm). The guard now + skips the whole death check on a partial listing. +- `cmd/gc/city_runtime.go:1899` — provider swap on config reload. Previously the + absorbed `(nil, nil)` let the swap proceed with zero visible sessions, silently + orphaning any still-alive session from tracking; the guard now keeps the old + config instead. + +The remaining site is diagnostics-only, not a safety change: -- `cmd/gc/city_runtime.go:960` — pool `on_death` hooks. On a full tmux outage - every pool slot vanishes from the empty listing at once, so the tick fires the - user's `on_death` command for EVERY pool slot: a false death storm. -- `cmd/gc/city_runtime.go:1899` — provider swap on config reload. - `cmd/gc/city_runtime.go:3466` / `:3478` — shutdown (and the force-shutdown - late-async-start re-list) session listing. + late-async-start re-list). Its stop set was already empty under the old + absorbed-error path, so its stop behavior is unchanged; only the stderr + message changes (from silent to an explicit "partial listing" diagnostic). +- Plus the pre-existing guards at `cmd/gc/adoption_barrier.go`, + `cmd/gc/cmd_stop.go` (stopOrphans / doStop), `cmd/gc/controller.go` + (runningSessionSet falls back to per-session last-known-good), + `cmd/gc/session_beads.go` (dead-cleanup / closed-bead reap), and + `internal/doctor/checks.go` (orphan check + `--fix`). -All four `IsPartialListError` guards at those call sites already exist (verified -in-tree), but none fires today because `ListRunning` returns a nil error on -`ErrNoServer`. The clean completion path is doc-only from here: emit the -EXISTING `PartialListError` from `Provider.ListRunning` / `Tmux.ListSessions` on -`ErrNoServer` (arm 6 below), which activates all four guards with no new -plumbing — the `on_death` storm is arm 4. +Implemented at the narrowest reconciler-facing layer: `Tmux.ListSessions` still +absorbs `ErrNoServer` into an empty result for its tmux-internal callers +(`FindSessionByWorkDir`, `CleanupOrphanedSessions`), which treat "server down" +and "no sessions" identically; a private `Tmux.listSessionNames` variant +propagates the cause so only `Provider.ListRunning` upgrades it to a partial +signal. Composite providers (`auto`, `hybrid`) already fold a backend's +`PartialListError` through `MergeBackendListResults`, so the signal propagates +unchanged. ### Bounded behavior change (maintainer, please confirm) @@ -86,12 +105,15 @@ partial runtime observation: `if err != nil { running = false }`): a failed reachability probe currently falls open to "not running"; it should treat `ErrRuntimeUnavailable` as partial and defer. -6. **`Tmux.ListSessions` / `Tmux.HasSession`** (`internal/runtime/tmux/tmux.go` - ~993-1018): these still return `nil,nil` / `false,nil` on `ErrNoServer` for - their (tmux-internal) callers. They are not on the reconciler liveness path - (that path is `list-panes` via `FetchState`), so they were left alone here; - surface `ErrRuntimeUnavailable` from them too for consistency once a consumer - needs it, auditing each internal caller to preserve today's absorb behavior. +6. **`Tmux.HasSession`** (`internal/runtime/tmux/tmux.go`): still returns + `false,nil` on `ErrNoServer` for its (tmux-internal) callers. It is not on the + reconciler liveness path (that path is `list-panes` via `FetchState`), so it + was left alone; surface `ErrRuntimeUnavailable` from it too for consistency + once a consumer needs it, auditing each internal caller to preserve today's + absorb behavior. (`Tmux.ListSessions` was the other half of this arm and is + now handled: `Provider.ListRunning` emits `PartialListError` on `ErrNoServer` + while `ListSessions` keeps absorbing it for its internal callers — see + "Landed (arm 6)" above.) The plumbing to get a per-tick `runtimeQueryPartial` to the reconciler arms (optional provider interface via type-assert, like `LivenessObserver`, plus a diff --git a/internal/runtime/tmux/adapter.go b/internal/runtime/tmux/adapter.go index 7f03c04053..6d81107042 100644 --- a/internal/runtime/tmux/adapter.go +++ b/internal/runtime/tmux/adapter.go @@ -613,9 +613,22 @@ func (p *Provider) Peek(name string, lines int) (string, error) { } // ListRunning returns all tmux session names matching the given prefix. +// +// A totally unreachable tmux server (ErrNoServer) is reported as a +// [runtime.PartialListError] with a nil names slice rather than an empty +// success: a single-tmux outage is a failed observation, not proof that zero +// sessions exist. This activates the reconciler-facing IsPartialListError +// guards (pool on_death, provider swap, shutdown listing, orphan cleanup) so a +// brief server blip defers destructive action instead of tearing down healthy +// sessions. It mirrors the multi-backend degraded-but-usable signal that +// [runtime.MergeBackendListResults] produces for composite providers, and is +// the ListRunning-side analog of the StateCache liveness fix in #4082. func (p *Provider) ListRunning(prefix string) ([]string, error) { - all, err := p.tm.ListSessions() + all, err := p.tm.listSessionNames() if err != nil { + if errors.Is(err, ErrNoServer) { + return nil, &runtime.PartialListError{Err: fmt.Errorf("tmux server unreachable: %w", err)} + } return nil, err } var matched []string diff --git a/internal/runtime/tmux/adapter_unit_test.go b/internal/runtime/tmux/adapter_unit_test.go index 262c3f3f70..847361ea6c 100644 --- a/internal/runtime/tmux/adapter_unit_test.go +++ b/internal/runtime/tmux/adapter_unit_test.go @@ -50,6 +50,60 @@ func TestProviderAttachMissingSessionWrapsRuntimeSentinel(t *testing.T) { } } +func TestProviderListRunningReportsPartialOnNoServer(t *testing.T) { + fe := &fakeExecutor{err: ErrNoServer} + p := NewProviderWithConfig(Config{SocketName: "x"}) + p.tm.exec = fe + + names, err := p.ListRunning("") + if names != nil { + t.Fatalf("ListRunning names = %v, want nil on unreachable server", names) + } + if !runtime.IsPartialListError(err) { + t.Fatalf("ListRunning err = %v, want runtime.PartialListError so reconciler guards defer", err) + } + if !errors.Is(err, ErrNoServer) { + t.Fatalf("ListRunning err = %v, want wrapped ErrNoServer cause", err) + } +} + +func TestProviderListRunningPropagatesNonServerError(t *testing.T) { + sentinel := errors.New("tmux exploded") + fe := &fakeExecutor{err: sentinel} + p := NewProviderWithConfig(Config{SocketName: "x"}) + p.tm.exec = fe + + names, err := p.ListRunning("") + if names != nil { + t.Fatalf("ListRunning names = %v, want nil on error", names) + } + if runtime.IsPartialListError(err) { + t.Fatalf("ListRunning err = %v, want a plain error (not partial) for a real tmux failure", err) + } + if !errors.Is(err, sentinel) { + t.Fatalf("ListRunning err = %v, want the underlying tmux error", err) + } +} + +// TestListSessionsAbsorbsNoServer pins the tmux-internal contract that the +// change deliberately preserves: ListSessions still reports an unreachable +// server as an empty result so FindSessionByWorkDir and CleanupOrphanedSessions +// keep treating "server down" as "no sessions". Only Provider.ListRunning +// surfaces the outage as a PartialListError. +func TestListSessionsAbsorbsNoServer(t *testing.T) { + fe := &fakeExecutor{err: ErrNoServer} + tm := NewTmux() + tm.exec = fe + + names, err := tm.ListSessions() + if err != nil { + t.Fatalf("ListSessions err = %v, want nil (no server absorbed)", err) + } + if names != nil { + t.Fatalf("ListSessions names = %v, want nil", names) + } +} + func TestProviderAttachReportsHasSessionError(t *testing.T) { fe := &fakeExecutor{ err: errors.New("tmux unavailable"), diff --git a/internal/runtime/tmux/tmux.go b/internal/runtime/tmux/tmux.go index 062106b595..5c75e82b62 100644 --- a/internal/runtime/tmux/tmux.go +++ b/internal/runtime/tmux/tmux.go @@ -1015,23 +1015,40 @@ func (t *Tmux) HasSession(name string) (bool, error) { return true, nil } -// ListSessions returns all session names. -func (t *Tmux) ListSessions() ([]string, error) { +// listSessionNames returns all session names, propagating ErrNoServer so +// callers that must distinguish an unreachable server from a genuinely empty +// session list can do so. [Tmux.ListSessions] absorbs ErrNoServer into an +// empty result for its tmux-internal callers; the reconciler-facing +// [Provider.ListRunning] uses this variant to surface a total outage as a +// [runtime.PartialListError] instead of "no sessions". +func (t *Tmux) listSessionNames() ([]string, error) { out, err := t.run("list-sessions", "-F", "#{session_name}") if err != nil { - if errors.Is(err, ErrNoServer) { - return nil, nil // No server = no sessions - } return nil, err } - if out == "" { return nil, nil } - return strings.Split(out, "\n"), nil } +// ListSessions returns all session names. An unreachable tmux server is +// absorbed into an empty result (no server = no sessions) for tmux-internal +// callers (FindSessionByWorkDir, CleanupOrphanedSessions) that treat "server +// down" and "no sessions" identically. Reconciler-facing liveness listing goes +// through [Provider.ListRunning], which instead reports the outage as a +// [runtime.PartialListError]. +func (t *Tmux) ListSessions() ([]string, error) { + names, err := t.listSessionNames() + if err != nil { + if errors.Is(err, ErrNoServer) { + return nil, nil // No server = no sessions + } + return nil, err + } + return names, nil +} + // SessionSet provides O(1) session existence checks by caching session names. // Use this when you need to check multiple sessions to avoid N+1 subprocess calls. type SessionSet struct { From 7eb0c70452f8ba29e8b8ea5d6d522e9370a73107 Mon Sep 17 00:00:00 2001 From: Stephanie Jarmak <36544495+sjarmak@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:40:55 -0400 Subject: [PATCH 010/225] fix(reconciler): repair stranded/dead-assignee work instead of only diagnosing (#4088) ## Why Two divergence-state classes let beads stall invisibly. Stranded in_progress work whose assignee is a dead pool worker was only *diagnosed*, never repaired, so it sat assigned to a dead session forever. And an open + `gc.routed_to` + dead-assignee bead is invisible to every probe (they all require `--unassigned`), so the pool never reclaims it. ## What changed - **Repair stranded pool-worker work** (`cmd/gc/session_reconciler.go`, `session_beads.go`): the stranded-pool-worker path now wires the existing, tested `unclaimWorkAssignedToRetiredSessionBead` + `closeBead` helpers to actually unassign/reopen the work and close the dead session bead, instead of only emitting a diagnostic. It fires only after confirmed continuous non-liveness (see below), and if any unassign fails it leaves the session bead open and retries next tick rather than closing over a stale assignee. - **Continuous-non-liveness confirmation window**: the repair gates on the `stranded_event_emitted_at` marker aging past a 2-minute grace, and (this is the load-bearing part) the marker is cleared on any tick where the session is observed alive (`clearStrandedEventMarker`, gated on `target.alive`, the exact complement of the `!target.alive` strand condition). So every distinct stranding episode ages a fresh window; a worker that strands, gets respawned/recovered, then re-strands cannot be repaired on a stale marker from the earlier episode. - **Observability for the existing Class-2 sweep** (`cmd/gc/dead_assignee_event.go`, `city_runtime.go`, event/openapi plumbing): the pre-existing `releaseOrphanedPoolAssignments` already clears open+routed+dead-assignee beads with full liveness gating; this adds a `bead.dead_assignee_reopened` event so those repairs are no longer silent. No new destructive path was added for Class 2. ## Test plan - `go build ./...`, `go vet ./cmd/gc/`, `gofmt -l`: clean - `go test ./cmd/gc/ -run 'Strand|DeadAssignee|Reassign|Unclaim|ReleaseOrphaned|Reconcile'`: pass - New tests: recover-then-restrand re-arms the window (repair defers on the fresh episode); continuous-window repair fires end-to-end through the real reconcile; a failed unassign keeps the session bead open; the dead-assignee event carries the typed payload. ## Known follow-up Narrow residual: if the *durable* marker-clear write fails repeatedly during a store outage aligned precisely with a respawn/re-strand window, a stale marker could survive and fire once. It is low-probability, defended by the `releaseOrphanedPoolAssignments` backstop, and is the same cross-restart durability tradeoff the diagnostic-emit path already accepts. A process-local recovered-since guard (mirroring the emit path) would close it; tracked as a follow-up rather than blocking this change. --------- Co-authored-by: sjarmak Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/gc/city_runtime.go | 5 + cmd/gc/dead_assignee_event.go | 61 +++++++ cmd/gc/dead_assignee_repair_test.go | 239 +++++++++++++++++++++++++++ cmd/gc/session_beads.go | 109 +++++++++++- cmd/gc/session_reconciler.go | 74 +++++++++ cmd/gc/session_reconciler_test.go | 164 ++++++++++++++++++ docs/reference/schema/openapi.json | 140 ++++++++++++++++ docs/reference/schema/openapi.txt | 140 ++++++++++++++++ internal/api/event_payloads.go | 29 ++++ internal/api/genclient/client_gen.go | 129 +++++++++++++++ internal/api/openapi.json | 140 ++++++++++++++++ internal/events/events.go | 34 ++-- 12 files changed, 1250 insertions(+), 14 deletions(-) create mode 100644 cmd/gc/dead_assignee_event.go create mode 100644 cmd/gc/dead_assignee_repair_test.go diff --git a/cmd/gc/city_runtime.go b/cmd/gc/city_runtime.go index 0a079e0cd4..eaa85d1850 100644 --- a/cmd/gc/city_runtime.go +++ b/cmd/gc/city_runtime.go @@ -2169,6 +2169,11 @@ func (cr *CityRuntime) beadReconcileTick(ctx context.Context, result DesiredStat for _, r := range released { fmt.Fprintf(cr.stderr, "released orphaned pool work: %s\n", r.ID) //nolint:errcheck } + // Turn the otherwise-silent reopen into an observable signal. The reopen + // (clear dead assignee, reset in_progress→open) already ran above and is + // gated on confirmed non-liveness; emit the event BEFORE the snapshot + // filter so the dead assignee and route can still be read off the beads. + emitDeadAssigneeReopenedEvents(cr.rec, assignedWorkBeads, released, time.Now()) assignedWorkBeads, assignedWorkStoreRefs = filterReleasedAssignedWorkSnapshot(assignedWorkBeads, assignedWorkStoreRefs, released) } // Squatter guard (gastownhall/gascity#2930): a foreign Dolt that has bound diff --git a/cmd/gc/dead_assignee_event.go b/cmd/gc/dead_assignee_event.go new file mode 100644 index 0000000000..1436a2e23a --- /dev/null +++ b/cmd/gc/dead_assignee_event.go @@ -0,0 +1,61 @@ +package main + +import ( + "strings" + "time" + + "github.com/gastownhall/gascity/internal/api" + "github.com/gastownhall/gascity/internal/beadmeta" + "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/events" +) + +// emitDeadAssigneeReopenedEvents records one bead.dead_assignee_reopened event +// for each work bead releaseOrphanedPoolAssignments just reopened because its +// assignee resolved to no open session bead. The destructive reopen (clear +// assignee, reset in_progress→open) already ran and is gated on confirmed +// non-liveness (snapshot-complete deferral + liveWorkAssignmentStillReleasable +// re-validation + liveOpenSessionAssignmentExists); this only makes the +// otherwise-silent repair observable, so it never mutates a bead. +// +// released carries the ID and the index into assignedWorkBeads (the pre-reopen +// snapshot) so the dead assignee and routed_to can be read off the bead as it +// looked when it was reopened. A stale/out-of-range index is skipped rather +// than fabricating a payload. +func emitDeadAssigneeReopenedEvents(rec events.Recorder, assignedWorkBeads []beads.Bead, released []releasedPoolAssignment, now time.Time) { + if rec == nil || len(released) == 0 { + return + } + for _, r := range released { + deadAssignee := "" + routedTo := "" + if r.Index >= 0 && r.Index < len(assignedWorkBeads) && assignedWorkBeads[r.Index].ID == r.ID { + wb := assignedWorkBeads[r.Index] + deadAssignee = strings.TrimSpace(wb.Assignee) + routedTo = strings.TrimSpace(wb.Metadata[beadmeta.RoutedToMetadataKey]) + } + rec.Record(events.Event{ + Type: events.BeadDeadAssigneeReopened, + Ts: now.UTC(), + Actor: "gc", + Subject: r.ID, + Message: formatDeadAssigneeReopenedMessage(r.ID, deadAssignee, routedTo), + Payload: api.BeadDeadAssigneeReopenedPayloadJSON(r.ID, deadAssignee, routedTo), + }) + } +} + +// formatDeadAssigneeReopenedMessage renders the operator-facing text for a +// bead.dead_assignee_reopened event. +func formatDeadAssigneeReopenedMessage(beadID, deadAssignee, routedTo string) string { + assignee := deadAssignee + if assignee == "" { + assignee = "" + } + route := routedTo + if route == "" { + route = "" + } + return "reopened routed work " + beadID + " assigned to dead session " + assignee + + " (route " + route + "); assignee cleared so the pool can reclaim it" +} diff --git a/cmd/gc/dead_assignee_repair_test.go b/cmd/gc/dead_assignee_repair_test.go new file mode 100644 index 0000000000..d072a0bbb1 --- /dev/null +++ b/cmd/gc/dead_assignee_repair_test.go @@ -0,0 +1,239 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/gastownhall/gascity/internal/api" + "github.com/gastownhall/gascity/internal/beadmeta" + "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/clock" + "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/events" +) + +// strandedRepairFixture creates a session bead plus an in_progress work bead +// assigned to it (by session ID), the stranded-pool-worker shape: the runtime +// is gone but the bead still holds the session as assignee. +func strandedRepairFixture(t *testing.T) (*beads.MemStore, beads.Bead, beads.Bead) { + t.Helper() + store := beads.NewMemStore() + session, err := store.Create(beads.Bead{ + Title: "worker session", + Type: sessionBeadType, + Status: "open", + Metadata: map[string]string{"session_name": "worker-mc-dead", "pool_managed": "true"}, + }) + if err != nil { + t.Fatalf("create session bead: %v", err) + } + work, err := store.Create(beads.Bead{ + Title: "stranded work", + Type: "task", + Assignee: session.ID, + Metadata: map[string]string{beadmeta.RoutedToMetadataKey: "worker"}, + }) + if err != nil { + t.Fatalf("create work bead: %v", err) + } + inProgress := "in_progress" + if err := store.Update(work.ID, beads.UpdateOpts{Status: &inProgress}); err != nil { + t.Fatalf("set work in_progress: %v", err) + } + work, _ = store.Get(work.ID) + return store, session, work +} + +// A confirmed-stranded pool worker (marker aged past the confirmation window) +// has its in_progress work unassigned + reopened and its session bead closed. +func TestRepairStrandedPoolWorkerBead_ReopensAfterConfirmationWindow(t *testing.T) { + store, session, work := strandedRepairFixture(t) + now := time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC) + // Diagnostic first observed the strand well past the confirmation window. + session.Metadata[strandedEventEmittedKey] = now.Add(-strandedRepairConfirmGrace - time.Minute).Format(time.RFC3339) + + var stderr bytes.Buffer + repaired := repairStrandedPoolWorkerBead(store, nil, &session, "worker", &clock.Fake{Time: now}, &stderr) + if !repaired { + t.Fatalf("expected repair to close the session bead; stderr=%q", stderr.String()) + } + + gotWork, _ := store.Get(work.ID) + if gotWork.Status != "open" { + t.Fatalf("work status = %q, want open", gotWork.Status) + } + if gotWork.Assignee != "" { + t.Fatalf("work assignee = %q, want empty", gotWork.Assignee) + } + gotSession, _ := store.Get(session.ID) + if gotSession.Status != "closed" { + t.Fatalf("session status = %q, want closed", gotSession.Status) + } +} + +// A single not-alive observation must never trigger the destructive clear: the +// marker is fresh (inside the window), so the work and session stay untouched. +func TestRepairStrandedPoolWorkerBead_DefersInsideConfirmationWindow(t *testing.T) { + store, session, work := strandedRepairFixture(t) + now := time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC) + session.Metadata[strandedEventEmittedKey] = now.Format(time.RFC3339) // just observed + + var stderr bytes.Buffer + if repairStrandedPoolWorkerBead(store, nil, &session, "worker", &clock.Fake{Time: now}, &stderr) { + t.Fatalf("must not repair inside the confirmation window") + } + gotWork, _ := store.Get(work.ID) + if gotWork.Status != "in_progress" || gotWork.Assignee != session.ID { + t.Fatalf("work should be untouched, got status=%q assignee=%q", gotWork.Status, gotWork.Assignee) + } + gotSession, _ := store.Get(session.ID) + if gotSession.Status != "open" { + t.Fatalf("session should stay open, got %q", gotSession.Status) + } +} + +// updateFailStore lists work normally but fails every Update, modeling a store +// where the unassign (ReleaseWorkBead → Update) cannot land. +type updateFailStore struct { + beads.Store +} + +func (s updateFailStore) Update(string, beads.UpdateOpts) error { + return fmt.Errorf("simulated update failure") +} + +// A partial failure (unassign does not land) must NOT be reported as a repair: +// the session bead stays open and the work stays claimed, so the stale-assignee +// item is left for the next-tick sweep rather than masked behind a "repaired" +// close. Surfaces the failure on stderr for distinct observability. +func TestRepairStrandedPoolWorkerBead_DefersAndKeepsSessionOpenWhenUnassignFails(t *testing.T) { + base, session, work := strandedRepairFixture(t) + store := updateFailStore{Store: base} + now := time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC) + // Marker aged well past the confirmation window — the window is satisfied; + // only the failed unassign should hold the repair back. + session.Metadata[strandedEventEmittedKey] = now.Add(-strandedRepairConfirmGrace - time.Minute).Format(time.RFC3339) + + var stderr bytes.Buffer + if repairStrandedPoolWorkerBead(store, nil, &session, "worker", &clock.Fake{Time: now}, &stderr) { + t.Fatal("repair must return false when an unassign does not land") + } + gotWork, _ := base.Get(work.ID) + if gotWork.Status != "in_progress" || gotWork.Assignee != session.ID { + t.Fatalf("work must stay claimed after a failed unassign, got status=%q assignee=%q", gotWork.Status, gotWork.Assignee) + } + gotSession, _ := base.Get(session.ID) + if gotSession.Status != "open" { + t.Fatalf("session must stay open after a failed unassign, got %q", gotSession.Status) + } + if !strings.Contains(stderr.String(), "unassign(s) failed") { + t.Fatalf("stderr must surface the failed unassign, got %q", stderr.String()) + } +} + +// Without a stranded marker the leak has not been confirmed this generation, so +// the repair defers even if the caller reached it — the diagnostic gates the +// destructive clear. +func TestRepairStrandedPoolWorkerBead_DefersWithoutStrandedMarker(t *testing.T) { + store, session, work := strandedRepairFixture(t) + now := time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC) + + var stderr bytes.Buffer + if repairStrandedPoolWorkerBead(store, nil, &session, "worker", &clock.Fake{Time: now}, &stderr) { + t.Fatalf("must not repair without a stranded marker") + } + gotWork, _ := store.Get(work.ID) + if gotWork.Status != "in_progress" { + t.Fatalf("work should be untouched, got status=%q", gotWork.Status) + } +} + +// A live named session's assigned work must survive the reopen sweep: an open +// session bead owning the identity means the session is not gone. Guards the +// conservative liveness primitive (open session bead exists → skip). +func TestReleaseOrphanedPoolAssignments_SkipsLiveAssigneeStaysAssigned(t *testing.T) { + store := beads.NewMemStore() + live, err := store.Create(beads.Bead{ + Title: "live worker", + Type: sessionBeadType, + Status: "open", + Metadata: map[string]string{"session_name": "worker-mc-live"}, + }) + if err != nil { + t.Fatalf("create session: %v", err) + } + work, err := store.Create(beads.Bead{ + Title: "routed work", + Assignee: live.Metadata["session_name"], + Metadata: map[string]string{beadmeta.RoutedToMetadataKey: "worker"}, + }) + if err != nil { + t.Fatalf("create work: %v", err) + } + inProgress := "in_progress" + if err := store.Update(work.ID, beads.UpdateOpts{Status: &inProgress}); err != nil { + t.Fatalf("set in_progress: %v", err) + } + work, _ = store.Get(work.ID) + + released := releaseOrphanedPoolAssignments( + store, + &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(2)}}}, + "", + []beads.Bead{live}, + []beads.Bead{work}, + nil, nil, nil, + ) + if len(released) != 0 { + t.Fatalf("live assignee must not be released, got %v", released) + } + got, _ := store.Get(work.ID) + if got.Assignee == "" { + t.Fatalf("live assignee cleared — should stay assigned") + } +} + +// emitDeadAssigneeReopenedEvents records one typed event per reopened bead, +// carrying the dead assignee and route read off the pre-filter snapshot. +func TestEmitDeadAssigneeReopenedEvents_EmitsTypedPayload(t *testing.T) { + assigned := []beads.Bead{ + {ID: "w-1", Assignee: "worker-mc-dead", Metadata: map[string]string{beadmeta.RoutedToMetadataKey: "worker"}}, + {ID: "w-2"}, // not released + } + released := []releasedPoolAssignment{{ID: "w-1", Index: 0}} + rec := &capturingRecorder{} + + emitDeadAssigneeReopenedEvents(rec, assigned, released, time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC)) + + if len(rec.events) != 1 { + t.Fatalf("event count = %d, want 1", len(rec.events)) + } + e := rec.events[0] + if e.Type != events.BeadDeadAssigneeReopened { + t.Fatalf("type = %q, want %q", e.Type, events.BeadDeadAssigneeReopened) + } + if e.Subject != "w-1" { + t.Fatalf("subject = %q, want w-1", e.Subject) + } + var p api.BeadDeadAssigneeReopenedPayload + if err := json.Unmarshal(e.Payload, &p); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + if p.BeadID != "w-1" || p.DeadAssignee != "worker-mc-dead" || p.RoutedTo != "worker" { + t.Fatalf("payload = %+v, want bead_id=w-1 dead_assignee=worker-mc-dead routed_to=worker", p) + } +} + +// A nil recorder or empty release list is a no-op — no panic, no events. +func TestEmitDeadAssigneeReopenedEvents_NoOpOnEmpty(t *testing.T) { + emitDeadAssigneeReopenedEvents(nil, nil, []releasedPoolAssignment{{ID: "x"}}, time.Now()) + rec := &capturingRecorder{} + emitDeadAssigneeReopenedEvents(rec, nil, nil, time.Now()) + if len(rec.events) != 0 { + t.Fatalf("expected no events, got %d", len(rec.events)) + } +} diff --git a/cmd/gc/session_beads.go b/cmd/gc/session_beads.go index 801f6f8c05..d332713c6e 100644 --- a/cmd/gc/session_beads.go +++ b/cmd/gc/session_beads.go @@ -725,15 +725,28 @@ func workAssignmentStores(store beads.Store, rigStores map[string]beads.Store) [ return stores } +// unclaimResult reports the outcome of one unassign sweep over a retired +// session bead's owned work: Released counts work beads whose assignee was +// successfully cleared/reopened, Failed counts ReleaseWorkBead errors (already +// logged per item to stderr). Void callers (named-session retirement, closed- +// session release) ignore it; the stranded-repair path reads Failed to avoid +// reporting a clean repair — or closing the session bead — when an unassign did +// not land, so a stale-assignee item is not masked behind a "repaired" close. +type unclaimResult struct { + Released int + Failed int +} + func unclaimWorkAssignedToRetiredSessionBead( store beads.Store, rigStores map[string]beads.Store, sessionBead beads.Bead, fallbackRoute string, stderr io.Writer, -) { +) unclaimResult { + var res unclaimResult if store == nil || strings.TrimSpace(sessionBead.ID) == "" { - return + return res } if stderr == nil { stderr = io.Discard @@ -769,11 +782,15 @@ func unclaimWorkAssignedToRetiredSessionBead( // reopen, orphan-pool, and closed-session release paths. if err := wa.ReleaseWorkBead(item, fallbackRoute); err != nil { fmt.Fprintf(stderr, "session beads: unclaiming work %s assigned to retired session %s: %v\n", item.ID, sessionBead.ID, err) //nolint:errcheck + res.Failed++ + continue } + res.Released++ } } } } + return res } func reassignWorkAssignedToRetiredSessionBead( @@ -818,6 +835,94 @@ func reassignWorkAssignedToRetiredSessionBead( } } +// strandedRepairConfirmGrace is the minimum age of the CURRENT stranding +// episode's stranded_event_emitted_at marker (stamped by +// emitSessionStrandedDiagnostic) before the reconciler will REPAIR — not merely +// diagnose — a stranded pool worker. The marker tracks CONTINUOUS non-liveness: +// clearStrandedEventMarker drops it on any alive observation, so the window +// re-arms from zero each time the session recovers. A single not-alive +// observation, or a worker that recovered and re-stranded, is never acted on +// until the NEW episode persists across the window, so a transient +// runtime-liveness glitch (or a recovered-then-cleanly-drained worker whose +// bd close is mid-flight) cannot clear a live claim. Mirrors the +// observe-before-act discipline of the idle-claim backstop (idleClaimNudgeGrace) +// and the #3630 suspend-confirm window. +const strandedRepairConfirmGrace = 2 * time.Minute + +// strandedRepairCloseReason is the close_reason stamped on a session bead +// retired by the stranded-worker repair, distinguishing it from a clean drain +// (drained) or an idle recycle in the forensic record. +const strandedRepairCloseReason = "stranded-repair" + +// repairStrandedPoolWorkerBead closes the divergence loop that +// emitSessionStrandedDiagnostic only reports: a pool session whose runtime +// exited while it still held in_progress work as assignee, leaving that work +// invisible to every actuator. It unassigns/reopens the stranded work (reusing +// unclaimWorkAssignedToRetiredSessionBead so the bead returns to the routed +// queue with a run_target fallback) and closes the session bead so the slot +// frees and the pool reclaims the work. +// +// Confirmed CONTINUOUS non-liveness is the contract: it only reaches here on a +// pool session the reconciler already sees as not-alive (poolFreeable requires +// !target.alive) with a non-degraded store read (!storeQueryPartial), and it +// acts only once the CURRENT stranding episode's stranded_event_emitted_at +// marker has aged past strandedRepairConfirmGrace. Because clearStrandedEventMarker +// drops that marker on every alive observation, the marker cannot outlive the +// episode that stamped it: a worker that stranded, was respawned on this same +// session bead, and recovered starts a brand-new marker if it re-strands, so a +// recovered-then-cleanly-drained worker (whose own bd close may be mid-flight +// during the brief poolFreeable && hasAssignedWork window) can never be repaired +// on a stale first-episode timestamp. An absent marker means no confirmed +// stranding episode is in progress (the diagnostic early-returned — no recorder, +// the work passed the detached-probe liveness filter, or the session recovered +// and cleared it), so the repair defers. +// +// The unassign step must land before the close: unclaimWorkAssignedToRetiredSessionBead +// reports how many releases failed via unclaimResult. If any failed, the session +// bead is left OPEN and false returned — closing it would retire the session +// while work is still assigned to it (a stale-assignee item), masking the leak +// behind a "repaired" close. A failed release is retried on the next tick (the +// episode's marker is still aged and the session still not-alive), and the +// self-healing next-tick sweep is the backstop. +// +// Returns true only when it BOTH cleared the stranded work AND closed the session +// bead, so the caller mirrors MarkClosed onto the snapshot and prunes the +// worktree exactly as the clean close path does. +func repairStrandedPoolWorkerBead( + store beads.Store, + rigStores map[string]beads.Store, + session *beads.Bead, + fallbackRoute string, + clk clock.Clock, + stderr io.Writer, +) bool { + if store == nil || session == nil { + return false + } + if stderr == nil { + stderr = io.Discard + } + since := strings.TrimSpace(session.Metadata[strandedEventEmittedKey]) + if since == "" { + return false // no confirmed stranding episode in progress — defer + } + first := parseRFC3339OrZero(since) + now := clk.Now().UTC() + if first.IsZero() || now.Sub(first) < strandedRepairConfirmGrace { + return false // inside the confirmation window — defer the destructive clear + } + res := unclaimWorkAssignedToRetiredSessionBead(store, rigStores, *session, fallbackRoute, stderr) + if res.Failed > 0 { + // At least one unassign did not land. Do NOT close the session bead or + // report a repair: closing now would strand the still-assigned work + // against a retired session. Leave the bead open so the next tick + // re-attempts (episode marker still aged, session still not-alive). + fmt.Fprintf(stderr, "session beads: stranded-repair for %s deferred: %d of %d unassign(s) failed; leaving session bead open for retry\n", session.ID, res.Failed, res.Failed+res.Released) //nolint:errcheck + return false + } + return closeBead(store, session.ID, strandedRepairCloseReason, now, stderr) +} + func reassignStateAssignedToRetiredSessionBead(store beads.Store, oldSessionID, newSessionID string, now time.Time, stderr io.Writer) { if store == nil || strings.TrimSpace(oldSessionID) == "" || strings.TrimSpace(newSessionID) == "" { return diff --git a/cmd/gc/session_reconciler.go b/cmd/gc/session_reconciler.go index 3ce651cf3f..c8fb57bcef 100644 --- a/cmd/gc/session_reconciler.go +++ b/cmd/gc/session_reconciler.go @@ -3216,6 +3216,18 @@ func reconcileSessionBeadsTracedWithNamedDemand( } persistSleepPolicyMetadata(target.session, sessFront, eval.Policy, eval.ConfigSuppressed) + // Clear-on-recovery: a live tick ends any stranding episode. Drop the + // stranded confirmation marker so stranded_event_emitted_at tracks + // CONTINUOUS non-liveness, not a one-shot flag — a worker that stranded, + // was respawned on this same session bead, and recovered must age a FRESH + // marker before repairStrandedPoolWorkerBead may act, rather than + // inheriting the first episode's stale timestamp. See clearStrandedEventMarker. + if target.alive { + if fold := clearStrandedEventMarker(target.session, sessFront, stderr); fold != nil { + infoByID[target.session.ID] = infoByID[target.session.ID].ApplyPatch(fold) + } + } + if shouldWake && !target.alive { // Session should be awake but isn't — wake it. if isFailedCreateSessionInfo(info) { @@ -3412,6 +3424,25 @@ func reconcileSessionBeadsTracedWithNamedDemand( if fold := emitSessionStrandedDiagnostic(cityPath, cfg, store, rigStores, target.session, target.tp.TemplateName, rec, clk, stderr); fold != nil { infoByID[target.session.ID] = infoByID[target.session.ID].ApplyPatch(fold) } + // Beyond diagnosis: once THIS stranding episode has been confirmed + // across the confirmation window (stranded_event_emitted_at aged past + // strandedRepairConfirmGrace) and the store read is non-degraded, + // REPAIR the leak — unassign/reopen the stranded work so the pool can + // reclaim it, then close the session bead to free the slot. The + // storeQueryPartial gate ensures a transient store miss can never clear + // a live claim. The confirmation window tracks CONTINUOUS non-liveness: + // clearStrandedEventMarker (invoked on every alive tick, above) drops + // the marker the instant the session is seen alive again, so a worker + // that stranded, was respawned on this same bead, and recovered must + // re-age a FRESH marker here — a recovered-then-drained worker cannot + // fire the repair on the first episode's stale timestamp. Reuses + // unclaimWorkAssignedToRetiredSessionBead, the same detach primitive + // named-session retirement uses. + if !storeQueryPartial && + repairStrandedPoolWorkerBead(store, rigStores, target.session, retiredSessionFallbackRoute(*target.session), clk, stderr) { + infoByID[target.session.ID] = infoByID[target.session.ID].MarkClosed() + pruneAgentHomeWorktreeIfSafe(*target.session, cityPath, cfg, stderr) + } } if poolFreeable && !hasAssignedWork { // Close directly rather than via closeSessionBeadIfUnassigned. @@ -3805,6 +3836,49 @@ func emitSessionStrandedDiagnostic( return sessionpkg.MetadataPatch{strandedEventEmittedKey: now.Format(time.RFC3339)} } +// clearStrandedEventMarker drops the stranded_event_emitted_at marker whenever +// the session is observed ALIVE again. This is the clear-on-recovery half of the +// confirmation-window contract: strandedEventEmittedKey tracks CONTINUOUS +// non-liveness, NOT a one-shot "ever stranded this generation" flag. +// +// Without it the marker is stamped once (emitSessionStrandedDiagnostic +// early-returns while it is set) and only cleared by a full session-bead close, +// so a pool worker that strands, is respawned on the SAME session bead +// (shouldWake && !alive → normal pool re-wake), recovers, and runs clean past +// strandedRepairConfirmGrace would inherit the stale first-episode timestamp. A +// later brief poolFreeable && hasAssignedWork window (the documented pre-close +// ownership race, session_reconciler.go ~3371-3374) would then let +// repairStrandedPoolWorkerBead read that long-aged marker and fire IMMEDIATELY, +// clearing a live claim on work the recovered worker finished cleanly. +// +// Clearing on any alive observation makes each distinct stranding episode age a +// FRESH marker: emitSessionStrandedDiagnostic re-emits per episode (restoring +// per-episode observability) and the repair must re-confirm non-liveness across +// a new window before it acts. alive ⟹ runtime is up ⟹ not stranded, so the +// clear is always safe here. +// +// Returns the metadata patch it applied so the reconciler folds it onto the +// infoByID snapshot (write-returns-Info), or nil when there was nothing to +// clear. Mirrors recordCurrentBeadIDOnWake: durable SetMarker first, then the +// in-memory session.Metadata mirror (the raw bead may be carried across ticks). +func clearStrandedEventMarker(session *beads.Bead, sessFront *sessionpkg.Store, stderr io.Writer) sessionpkg.MetadataPatch { + if session == nil || sessFront == nil { + return nil + } + if strings.TrimSpace(session.Metadata[strandedEventEmittedKey]) == "" { + return nil // no marker this generation — nothing to clear + } + // Empty value clears the key (SetMarker empty-string-clear contract). + if err := sessFront.SetMarker(session.ID, strandedEventEmittedKey, ""); err != nil { + if stderr != nil { + fmt.Fprintf(stderr, "session reconciler: clearing %s for %s: %v\n", strandedEventEmittedKey, session.Metadata["session_name"], err) //nolint:errcheck + } + return nil + } + delete(session.Metadata, strandedEventEmittedKey) + return sessionpkg.MetadataPatch{strandedEventEmittedKey: ""} +} + type strandedAssignedWork struct { bead beads.Bead store beads.Store diff --git a/cmd/gc/session_reconciler_test.go b/cmd/gc/session_reconciler_test.go index 4fd873e412..abd25efbb4 100644 --- a/cmd/gc/session_reconciler_test.go +++ b/cmd/gc/session_reconciler_test.go @@ -2282,6 +2282,170 @@ func TestReconcileSessionBeads_PoolSlotWithStrandedWorkEmitsDiagnostic(t *testin } } +// strandedRepairReconcileEnv builds a pool-managed session whose runtime is dead +// while it still holds one in_progress work bead as assignee — the exact shape +// TestReconcileSessionBeads_PoolSlotWithStrandedWorkEmitsDiagnostic exercises, +// so poolFreeable && hasAssignedWork holds and the diagnostic + repair path is +// reached through the real reconcile call site. +func strandedRepairReconcileEnv(t *testing.T) (*reconcilerTestEnv, beads.Bead, beads.Bead, *capturingRecorder) { + t.Helper() + env := newReconcilerTestEnv() + env.cfg = &config.City{Agents: []config.Agent{{Name: "worker"}}} + env.addDesired("worker", "worker", false) // runtime NOT running — dead + session := env.createSessionBead("worker", "worker") + env.setSessionMetadata(&session, map[string]string{ + "state": "asleep", + "sleep_reason": "idle", + poolManagedMetadataKey: boolMetadata(true), + }) + work, err := env.store.Create(beads.Bead{ + Title: "stranded implementation", + Type: "task", + Status: "open", + Assignee: session.ID, + }) + if err != nil { + t.Fatalf("Create work bead: %v", err) + } + inProgress := "in_progress" + if err := env.store.Update(work.ID, beads.UpdateOpts{Status: &inProgress}); err != nil { + t.Fatalf("Update work bead status: %v", err) + } + work, _ = env.store.Get(work.ID) + rec := &capturingRecorder{} + env.rec = rec + return env, session, work, rec +} + +// runStrandedReconcileTick drives one full reconcile tick through the real +// call site with the standard stranded-repair fixture arguments. +func runStrandedReconcileTick(t *testing.T, env *reconcilerTestEnv, sessions []beads.Bead) { + t.Helper() + reconcileSessionBeadsAtPath( + context.Background(), + "", + sessions, + env.desiredState, + map[string]bool{"worker": true}, + env.cfg, + env.sp, + env.store, + newFakeDrainOps(), + nil, + nil, + nil, + env.dt, + nil, + false, + nil, + "", + nil, + env.clk, + env.rec, + 0, + 0, + &env.stdout, + &env.stderr, + ) +} + +// After a genuine CONTINUOUS non-liveness window the reconciler must actually +// REPAIR the stranded work end-to-end: unassign/reopen the work and close the +// session bead. The existing tests only cover the defer path; this asserts the +// fire path through the real reconcile. +func TestReconcileSessionBeads_StrandedRepairFiresAfterContinuousWindow(t *testing.T) { + env, session, work, rec := strandedRepairReconcileEnv(t) + + // Tick 1: diagnostic fires and stamps stranded_event_emitted_at; the repair + // defers because the marker is fresh (inside the confirmation window). + runStrandedReconcileTick(t, env, []beads.Bead{session}) + if got := len(rec.strandedEvents()); got != 1 { + t.Fatalf("stranded events after tick 1 = %d, want 1; events: %+v", got, rec.events) + } + afterFirst, _ := env.store.Get(session.ID) + if afterFirst.Status == "closed" { + t.Fatal("session must not be closed inside the confirmation window") + } + + // Advance past the confirmation window; the runtime stayed dead throughout + // (continuous non-liveness), so the marker is never cleared. + env.clk.Time = env.clk.Time.Add(strandedRepairConfirmGrace + time.Minute) + updated, _ := env.store.Get(session.ID) + + // Tick 2: window satisfied → repair fires. + runStrandedReconcileTick(t, env, []beads.Bead{updated}) + + gotWork, _ := env.store.Get(work.ID) + if gotWork.Status != "open" { + t.Fatalf("work status = %q, want open (reopened by repair)", gotWork.Status) + } + if gotWork.Assignee != "" { + t.Fatalf("work assignee = %q, want empty (unassigned by repair)", gotWork.Assignee) + } + gotSession, _ := env.store.Get(session.ID) + if gotSession.Status != "closed" { + t.Fatalf("session status = %q, want closed (slot freed by repair)", gotSession.Status) + } +} + +// Regression for the bypassable confirmation window: a worker that strands, is +// respawned on the SAME session bead, recovers (observed alive for a tick), then +// re-strands must NOT be repaired on the first episode's stale marker. The alive +// tick clears stranded_event_emitted_at, so episode 2 stamps a FRESH marker and +// the repair defers again even though wall-clock time is well past the window. +func TestReconcileSessionBeads_StrandedRepairReArmsWindowAfterRecovery(t *testing.T) { + env, session, work, rec := strandedRepairReconcileEnv(t) + + // Episode 1: dead runtime + assigned in_progress work → diagnostic stamps + // the confirmation marker. + runStrandedReconcileTick(t, env, []beads.Bead{session}) + if got := len(rec.strandedEvents()); got != 1 { + t.Fatalf("stranded events after episode 1 = %d, want 1", got) + } + afterEp1, _ := env.store.Get(session.ID) + if strings.TrimSpace(afterEp1.Metadata[strandedEventEmittedKey]) == "" { + t.Fatal("episode 1 must stamp stranded_event_emitted_at") + } + + // Recovery: the pool respawns the worker on the same session bead; the next + // tick observes it ALIVE. clearStrandedEventMarker must drop the marker. + if err := env.sp.Start(context.Background(), "worker", runtime.Config{Command: "test-cmd"}); err != nil { + t.Fatalf("Start (respawn): %v", err) + } + recovered, _ := env.store.Get(session.ID) + runStrandedReconcileTick(t, env, []beads.Bead{recovered}) + afterRecovery, _ := env.store.Get(session.ID) + if got := strings.TrimSpace(afterRecovery.Metadata[strandedEventEmittedKey]); got != "" { + t.Fatalf("stranded marker must be cleared on an alive tick, got %q", got) + } + + // Advance well past a window that episode 1's marker would have satisfied. + env.clk.Time = env.clk.Time.Add(strandedRepairConfirmGrace + time.Minute) + + // Episode 2: the worker re-strands (runtime dead again) still holding the + // same in_progress work. + if err := env.sp.Stop("worker"); err != nil { + t.Fatalf("Stop (re-strand): %v", err) + } + restranded, _ := env.store.Get(session.ID) + runStrandedReconcileTick(t, env, []beads.Bead{restranded}) + + // The repair must DEFER: episode 2's marker was just stamped, so it is inside + // a fresh window. The live claim on the work must survive. + gotWork, _ := env.store.Get(work.ID) + if gotWork.Status != "in_progress" || gotWork.Assignee != session.ID { + t.Fatalf("work must stay claimed (repair fired on a stale window); got status=%q assignee=%q", gotWork.Status, gotWork.Assignee) + } + gotSession, _ := env.store.Get(session.ID) + if gotSession.Status == "closed" { + t.Fatal("session must stay open (repair fired on a stale window)") + } + // Per-episode observability: a distinct diagnostic fired for episode 2. + if got := len(rec.strandedEvents()); got != 2 { + t.Fatalf("stranded events = %d, want 2 (one per episode)", got) + } +} + func TestCollectSessionAssignedWorkIncludesAssignedWisp(t *testing.T) { store := beads.NewMemStore() session := beads.Bead{ diff --git a/docs/reference/schema/openapi.json b/docs/reference/schema/openapi.json index 0b886fcb59..3076be27f4 100644 --- a/docs/reference/schema/openapi.json +++ b/docs/reference/schema/openapi.json @@ -1024,6 +1024,27 @@ ], "type": "object" }, + "BeadDeadAssigneeReopenedPayload": { + "additionalProperties": false, + "properties": { + "bead_id": { + "description": "ID of the reopened work bead (also the envelope Subject).", + "type": "string" + }, + "dead_assignee": { + "description": "The assignee identity that resolved to no open session bead, cleared by the reopen.", + "type": "string" + }, + "routed_to": { + "description": "The gc.routed_to target the bead stays routed to after the reopen, when set.", + "type": "string" + } + }, + "required": [ + "bead_id" + ], + "type": "object" + }, "BeadDepsResponse": { "additionalProperties": false, "properties": { @@ -2203,6 +2224,9 @@ { "$ref": "#/components/schemas/BeadClaimRejectedPayload" }, + { + "$ref": "#/components/schemas/BeadDeadAssigneeReopenedPayload" + }, { "$ref": "#/components/schemas/BeadEventPayload" }, @@ -8234,6 +8258,7 @@ "bead.claim_rejected": "#/components/schemas/TypedEventStreamEnvelopeBeadClaimRejected", "bead.closed": "#/components/schemas/TypedEventStreamEnvelopeBeadClosed", "bead.created": "#/components/schemas/TypedEventStreamEnvelopeBeadCreated", + "bead.dead_assignee_reopened": "#/components/schemas/TypedEventStreamEnvelopeBeadDeadAssigneeReopened", "bead.deleted": "#/components/schemas/TypedEventStreamEnvelopeBeadDeleted", "bead.updated": "#/components/schemas/TypedEventStreamEnvelopeBeadUpdated", "bead.worktree.reap_skipped": "#/components/schemas/TypedEventStreamEnvelopeBeadWorktreeReapSkipped", @@ -8316,6 +8341,9 @@ { "$ref": "#/components/schemas/TypedEventStreamEnvelopeBeadCreated" }, + { + "$ref": "#/components/schemas/TypedEventStreamEnvelopeBeadDeadAssigneeReopened" + }, { "$ref": "#/components/schemas/TypedEventStreamEnvelopeBeadDeleted" }, @@ -8682,6 +8710,57 @@ "title": "TypedEventStreamEnvelope bead.created", "type": "object" }, + "TypedEventStreamEnvelopeBeadDeadAssigneeReopened": { + "additionalProperties": false, + "properties": { + "actor": { + "type": "string" + }, + "message": { + "type": "string" + }, + "payload": { + "$ref": "#/components/schemas/BeadDeadAssigneeReopenedPayload" + }, + "run_id": { + "type": "string" + }, + "seq": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "session_id": { + "type": "string" + }, + "step_id": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "ts": { + "format": "date-time", + "type": "string" + }, + "type": { + "const": "bead.dead_assignee_reopened", + "type": "string" + }, + "workflow": { + "$ref": "#/components/schemas/WorkflowEventProjection" + } + }, + "required": [ + "seq", + "type", + "ts", + "actor", + "payload" + ], + "title": "TypedEventStreamEnvelope bead.dead_assignee_reopened", + "type": "object" + }, "TypedEventStreamEnvelopeBeadDeleted": { "additionalProperties": false, "properties": { @@ -9350,6 +9429,7 @@ "bead.worktree.reaped", "bead.worktree.reap_skipped", "bead.claim_rejected", + "bead.dead_assignee_reopened", "mail.sent", "mail.read", "mail.archived", @@ -12332,6 +12412,7 @@ "bead.claim_rejected": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadClaimRejected", "bead.closed": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadClosed", "bead.created": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadCreated", + "bead.dead_assignee_reopened": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened", "bead.deleted": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadDeleted", "bead.updated": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadUpdated", "bead.worktree.reap_skipped": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadWorktreeReapSkipped", @@ -12414,6 +12495,9 @@ { "$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadCreated" }, + { + "$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened" + }, { "$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadDeleted" }, @@ -12792,6 +12876,61 @@ "title": "TypedTaggedEventStreamEnvelope bead.created", "type": "object" }, + "TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened": { + "additionalProperties": false, + "properties": { + "actor": { + "type": "string" + }, + "city": { + "type": "string" + }, + "message": { + "type": "string" + }, + "payload": { + "$ref": "#/components/schemas/BeadDeadAssigneeReopenedPayload" + }, + "run_id": { + "type": "string" + }, + "seq": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "session_id": { + "type": "string" + }, + "step_id": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "ts": { + "format": "date-time", + "type": "string" + }, + "type": { + "const": "bead.dead_assignee_reopened", + "type": "string" + }, + "workflow": { + "$ref": "#/components/schemas/WorkflowEventProjection" + } + }, + "required": [ + "seq", + "type", + "ts", + "actor", + "payload", + "city" + ], + "title": "TypedTaggedEventStreamEnvelope bead.dead_assignee_reopened", + "type": "object" + }, "TypedTaggedEventStreamEnvelopeBeadDeleted": { "additionalProperties": false, "properties": { @@ -13511,6 +13650,7 @@ "bead.worktree.reaped", "bead.worktree.reap_skipped", "bead.claim_rejected", + "bead.dead_assignee_reopened", "mail.sent", "mail.read", "mail.archived", diff --git a/docs/reference/schema/openapi.txt b/docs/reference/schema/openapi.txt index 0b886fcb59..3076be27f4 100644 --- a/docs/reference/schema/openapi.txt +++ b/docs/reference/schema/openapi.txt @@ -1024,6 +1024,27 @@ ], "type": "object" }, + "BeadDeadAssigneeReopenedPayload": { + "additionalProperties": false, + "properties": { + "bead_id": { + "description": "ID of the reopened work bead (also the envelope Subject).", + "type": "string" + }, + "dead_assignee": { + "description": "The assignee identity that resolved to no open session bead, cleared by the reopen.", + "type": "string" + }, + "routed_to": { + "description": "The gc.routed_to target the bead stays routed to after the reopen, when set.", + "type": "string" + } + }, + "required": [ + "bead_id" + ], + "type": "object" + }, "BeadDepsResponse": { "additionalProperties": false, "properties": { @@ -2203,6 +2224,9 @@ { "$ref": "#/components/schemas/BeadClaimRejectedPayload" }, + { + "$ref": "#/components/schemas/BeadDeadAssigneeReopenedPayload" + }, { "$ref": "#/components/schemas/BeadEventPayload" }, @@ -8234,6 +8258,7 @@ "bead.claim_rejected": "#/components/schemas/TypedEventStreamEnvelopeBeadClaimRejected", "bead.closed": "#/components/schemas/TypedEventStreamEnvelopeBeadClosed", "bead.created": "#/components/schemas/TypedEventStreamEnvelopeBeadCreated", + "bead.dead_assignee_reopened": "#/components/schemas/TypedEventStreamEnvelopeBeadDeadAssigneeReopened", "bead.deleted": "#/components/schemas/TypedEventStreamEnvelopeBeadDeleted", "bead.updated": "#/components/schemas/TypedEventStreamEnvelopeBeadUpdated", "bead.worktree.reap_skipped": "#/components/schemas/TypedEventStreamEnvelopeBeadWorktreeReapSkipped", @@ -8316,6 +8341,9 @@ { "$ref": "#/components/schemas/TypedEventStreamEnvelopeBeadCreated" }, + { + "$ref": "#/components/schemas/TypedEventStreamEnvelopeBeadDeadAssigneeReopened" + }, { "$ref": "#/components/schemas/TypedEventStreamEnvelopeBeadDeleted" }, @@ -8682,6 +8710,57 @@ "title": "TypedEventStreamEnvelope bead.created", "type": "object" }, + "TypedEventStreamEnvelopeBeadDeadAssigneeReopened": { + "additionalProperties": false, + "properties": { + "actor": { + "type": "string" + }, + "message": { + "type": "string" + }, + "payload": { + "$ref": "#/components/schemas/BeadDeadAssigneeReopenedPayload" + }, + "run_id": { + "type": "string" + }, + "seq": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "session_id": { + "type": "string" + }, + "step_id": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "ts": { + "format": "date-time", + "type": "string" + }, + "type": { + "const": "bead.dead_assignee_reopened", + "type": "string" + }, + "workflow": { + "$ref": "#/components/schemas/WorkflowEventProjection" + } + }, + "required": [ + "seq", + "type", + "ts", + "actor", + "payload" + ], + "title": "TypedEventStreamEnvelope bead.dead_assignee_reopened", + "type": "object" + }, "TypedEventStreamEnvelopeBeadDeleted": { "additionalProperties": false, "properties": { @@ -9350,6 +9429,7 @@ "bead.worktree.reaped", "bead.worktree.reap_skipped", "bead.claim_rejected", + "bead.dead_assignee_reopened", "mail.sent", "mail.read", "mail.archived", @@ -12332,6 +12412,7 @@ "bead.claim_rejected": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadClaimRejected", "bead.closed": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadClosed", "bead.created": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadCreated", + "bead.dead_assignee_reopened": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened", "bead.deleted": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadDeleted", "bead.updated": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadUpdated", "bead.worktree.reap_skipped": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadWorktreeReapSkipped", @@ -12414,6 +12495,9 @@ { "$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadCreated" }, + { + "$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened" + }, { "$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadDeleted" }, @@ -12792,6 +12876,61 @@ "title": "TypedTaggedEventStreamEnvelope bead.created", "type": "object" }, + "TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened": { + "additionalProperties": false, + "properties": { + "actor": { + "type": "string" + }, + "city": { + "type": "string" + }, + "message": { + "type": "string" + }, + "payload": { + "$ref": "#/components/schemas/BeadDeadAssigneeReopenedPayload" + }, + "run_id": { + "type": "string" + }, + "seq": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "session_id": { + "type": "string" + }, + "step_id": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "ts": { + "format": "date-time", + "type": "string" + }, + "type": { + "const": "bead.dead_assignee_reopened", + "type": "string" + }, + "workflow": { + "$ref": "#/components/schemas/WorkflowEventProjection" + } + }, + "required": [ + "seq", + "type", + "ts", + "actor", + "payload", + "city" + ], + "title": "TypedTaggedEventStreamEnvelope bead.dead_assignee_reopened", + "type": "object" + }, "TypedTaggedEventStreamEnvelopeBeadDeleted": { "additionalProperties": false, "properties": { @@ -13511,6 +13650,7 @@ "bead.worktree.reaped", "bead.worktree.reap_skipped", "bead.claim_rejected", + "bead.dead_assignee_reopened", "mail.sent", "mail.read", "mail.archived", diff --git a/internal/api/event_payloads.go b/internal/api/event_payloads.go index b8ceca7380..8c92b711ab 100644 --- a/internal/api/event_payloads.go +++ b/internal/api/event_payloads.go @@ -505,6 +505,34 @@ func SessionStrandedPayloadJSON(sessionID, sessionName, template string, workBea return b } +// BeadDeadAssigneeReopenedPayload is the typed payload for +// bead.dead_assignee_reopened events. Emitted when the reconciler reopens a +// routed work bead whose assignee no longer maps to any open session bead — +// the owning session closed/retired while the bead stayed assigned, so it sat +// open+routed but unclaimable. The reconciler clears DeadAssignee (empty-string +// clear) so the RoutedTo pool can reclaim BeadID; the payload makes the repair +// observable for eval/audit (mirrors BeadClaimRejectedPayload). +type BeadDeadAssigneeReopenedPayload struct { + BeadID string `json:"bead_id" doc:"ID of the reopened work bead (also the envelope Subject)."` + DeadAssignee string `json:"dead_assignee,omitempty" doc:"The assignee identity that resolved to no open session bead, cleared by the reopen."` + RoutedTo string `json:"routed_to,omitempty" doc:"The gc.routed_to target the bead stays routed to after the reopen, when set."` +} + +// IsEventPayload marks BeadDeadAssigneeReopenedPayload as an events.Payload variant. +func (BeadDeadAssigneeReopenedPayload) IsEventPayload() {} + +// BeadDeadAssigneeReopenedPayloadJSON builds the JSON wire form for attachment +// to an events.Event.Payload field. DeadAssignee and RoutedTo are emitted only +// when non-empty. +func BeadDeadAssigneeReopenedPayloadJSON(beadID, deadAssignee, routedTo string) json.RawMessage { + b, _ := json.Marshal(BeadDeadAssigneeReopenedPayload{ + BeadID: beadID, + DeadAssignee: deadAssignee, + RoutedTo: routedTo, + }) + return b +} + func init() { // mail.* — all seven types share one payload shape. events.RegisterPayload(events.MailSent, MailEventPayload{}) @@ -520,6 +548,7 @@ func init() { events.RegisterPayload(events.BeadUpdated, BeadEventPayload{}) events.RegisterPayload(events.BeadClosed, BeadEventPayload{}) events.RegisterPayload(events.BeadDeleted, BeadEventPayload{}) + events.RegisterPayload(events.BeadDeadAssigneeReopened, BeadDeadAssigneeReopenedPayload{}) // session.* / convoy.* / controller.* / city.* / order.* / // provider.* — these events carry no structured payload today; diff --git a/internal/api/genclient/client_gen.go b/internal/api/genclient/client_gen.go index 43621f8fee..0f5a59ee4f 100644 --- a/internal/api/genclient/client_gen.go +++ b/internal/api/genclient/client_gen.go @@ -657,6 +657,18 @@ type BeadCreateInputBody struct { Type *string `json:"type,omitempty"` } +// BeadDeadAssigneeReopenedPayload defines model for BeadDeadAssigneeReopenedPayload. +type BeadDeadAssigneeReopenedPayload struct { + // BeadId ID of the reopened work bead (also the envelope Subject). + BeadId string `json:"bead_id"` + + // DeadAssignee The assignee identity that resolved to no open session bead, cleared by the reopen. + DeadAssignee *string `json:"dead_assignee,omitempty"` + + // RoutedTo The gc.routed_to target the bead stays routed to after the reopen, when set. + RoutedTo *string `json:"routed_to,omitempty"` +} + // BeadDepsResponse defines model for BeadDepsResponse. type BeadDepsResponse struct { Children *[]Bead `json:"children"` @@ -3484,6 +3496,21 @@ type TypedEventStreamEnvelopeBeadCreated struct { Workflow *WorkflowEventProjection `json:"workflow,omitempty"` } +// TypedEventStreamEnvelopeBeadDeadAssigneeReopened defines model for TypedEventStreamEnvelopeBeadDeadAssigneeReopened. +type TypedEventStreamEnvelopeBeadDeadAssigneeReopened struct { + Actor string `json:"actor"` + Message *string `json:"message,omitempty"` + Payload BeadDeadAssigneeReopenedPayload `json:"payload"` + RunId *string `json:"run_id,omitempty"` + Seq int64 `json:"seq"` + SessionId *string `json:"session_id,omitempty"` + StepId *string `json:"step_id,omitempty"` + Subject *string `json:"subject,omitempty"` + Ts time.Time `json:"ts"` + Type string `json:"type"` + Workflow *WorkflowEventProjection `json:"workflow,omitempty"` +} + // TypedEventStreamEnvelopeBeadDeleted defines model for TypedEventStreamEnvelopeBeadDeleted. type TypedEventStreamEnvelopeBeadDeleted struct { Actor string `json:"actor"` @@ -4587,6 +4614,22 @@ type TypedTaggedEventStreamEnvelopeBeadCreated struct { Workflow *WorkflowEventProjection `json:"workflow,omitempty"` } +// TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened defines model for TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened. +type TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened struct { + Actor string `json:"actor"` + City string `json:"city"` + Message *string `json:"message,omitempty"` + Payload BeadDeadAssigneeReopenedPayload `json:"payload"` + RunId *string `json:"run_id,omitempty"` + Seq int64 `json:"seq"` + SessionId *string `json:"session_id,omitempty"` + StepId *string `json:"step_id,omitempty"` + Subject *string `json:"subject,omitempty"` + Ts time.Time `json:"ts"` + Type string `json:"type"` + Workflow *WorkflowEventProjection `json:"workflow,omitempty"` +} + // TypedTaggedEventStreamEnvelopeBeadDeleted defines model for TypedTaggedEventStreamEnvelopeBeadDeleted. type TypedTaggedEventStreamEnvelopeBeadDeleted struct { Actor string `json:"actor"` @@ -7106,6 +7149,32 @@ func (t *EventPayload) MergeBeadClaimRejectedPayload(v BeadClaimRejectedPayload) return err } +// AsBeadDeadAssigneeReopenedPayload returns the union data inside the EventPayload as a BeadDeadAssigneeReopenedPayload +func (t EventPayload) AsBeadDeadAssigneeReopenedPayload() (BeadDeadAssigneeReopenedPayload, error) { + var body BeadDeadAssigneeReopenedPayload + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromBeadDeadAssigneeReopenedPayload overwrites any union data inside the EventPayload as the provided BeadDeadAssigneeReopenedPayload +func (t *EventPayload) FromBeadDeadAssigneeReopenedPayload(v BeadDeadAssigneeReopenedPayload) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeBeadDeadAssigneeReopenedPayload performs a merge with any union data inside the EventPayload, using the provided BeadDeadAssigneeReopenedPayload +func (t *EventPayload) MergeBeadDeadAssigneeReopenedPayload(v BeadDeadAssigneeReopenedPayload) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + // AsBeadEventPayload returns the union data inside the EventPayload as a BeadEventPayload func (t EventPayload) AsBeadEventPayload() (BeadEventPayload, error) { var body BeadEventPayload @@ -8276,6 +8345,34 @@ func (t *TypedEventStreamEnvelope) MergeTypedEventStreamEnvelopeBeadCreated(v Ty return err } +// AsTypedEventStreamEnvelopeBeadDeadAssigneeReopened returns the union data inside the TypedEventStreamEnvelope as a TypedEventStreamEnvelopeBeadDeadAssigneeReopened +func (t TypedEventStreamEnvelope) AsTypedEventStreamEnvelopeBeadDeadAssigneeReopened() (TypedEventStreamEnvelopeBeadDeadAssigneeReopened, error) { + var body TypedEventStreamEnvelopeBeadDeadAssigneeReopened + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTypedEventStreamEnvelopeBeadDeadAssigneeReopened overwrites any union data inside the TypedEventStreamEnvelope as the provided TypedEventStreamEnvelopeBeadDeadAssigneeReopened +func (t *TypedEventStreamEnvelope) FromTypedEventStreamEnvelopeBeadDeadAssigneeReopened(v TypedEventStreamEnvelopeBeadDeadAssigneeReopened) error { + v.Type = "bead.dead_assignee_reopened" + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTypedEventStreamEnvelopeBeadDeadAssigneeReopened performs a merge with any union data inside the TypedEventStreamEnvelope, using the provided TypedEventStreamEnvelopeBeadDeadAssigneeReopened +func (t *TypedEventStreamEnvelope) MergeTypedEventStreamEnvelopeBeadDeadAssigneeReopened(v TypedEventStreamEnvelopeBeadDeadAssigneeReopened) error { + v.Type = "bead.dead_assignee_reopened" + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + // AsTypedEventStreamEnvelopeBeadDeleted returns the union data inside the TypedEventStreamEnvelope as a TypedEventStreamEnvelopeBeadDeleted func (t TypedEventStreamEnvelope) AsTypedEventStreamEnvelopeBeadDeleted() (TypedEventStreamEnvelopeBeadDeleted, error) { var body TypedEventStreamEnvelopeBeadDeleted @@ -10258,6 +10355,8 @@ func (t TypedEventStreamEnvelope) ValueByDiscriminator() (interface{}, error) { return t.AsTypedEventStreamEnvelopeBeadClosed() case "bead.created": return t.AsTypedEventStreamEnvelopeBeadCreated() + case "bead.dead_assignee_reopened": + return t.AsTypedEventStreamEnvelopeBeadDeadAssigneeReopened() case "bead.deleted": return t.AsTypedEventStreamEnvelopeBeadDeleted() case "bead.updated": @@ -10495,6 +10594,34 @@ func (t *TypedTaggedEventStreamEnvelope) MergeTypedTaggedEventStreamEnvelopeBead return err } +// AsTypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened returns the union data inside the TypedTaggedEventStreamEnvelope as a TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened +func (t TypedTaggedEventStreamEnvelope) AsTypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened() (TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened, error) { + var body TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened overwrites any union data inside the TypedTaggedEventStreamEnvelope as the provided TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened +func (t *TypedTaggedEventStreamEnvelope) FromTypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened(v TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened) error { + v.Type = "bead.dead_assignee_reopened" + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened performs a merge with any union data inside the TypedTaggedEventStreamEnvelope, using the provided TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened +func (t *TypedTaggedEventStreamEnvelope) MergeTypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened(v TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened) error { + v.Type = "bead.dead_assignee_reopened" + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + // AsTypedTaggedEventStreamEnvelopeBeadDeleted returns the union data inside the TypedTaggedEventStreamEnvelope as a TypedTaggedEventStreamEnvelopeBeadDeleted func (t TypedTaggedEventStreamEnvelope) AsTypedTaggedEventStreamEnvelopeBeadDeleted() (TypedTaggedEventStreamEnvelopeBeadDeleted, error) { var body TypedTaggedEventStreamEnvelopeBeadDeleted @@ -12477,6 +12604,8 @@ func (t TypedTaggedEventStreamEnvelope) ValueByDiscriminator() (interface{}, err return t.AsTypedTaggedEventStreamEnvelopeBeadClosed() case "bead.created": return t.AsTypedTaggedEventStreamEnvelopeBeadCreated() + case "bead.dead_assignee_reopened": + return t.AsTypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened() case "bead.deleted": return t.AsTypedTaggedEventStreamEnvelopeBeadDeleted() case "bead.updated": diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 0b886fcb59..3076be27f4 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -1024,6 +1024,27 @@ ], "type": "object" }, + "BeadDeadAssigneeReopenedPayload": { + "additionalProperties": false, + "properties": { + "bead_id": { + "description": "ID of the reopened work bead (also the envelope Subject).", + "type": "string" + }, + "dead_assignee": { + "description": "The assignee identity that resolved to no open session bead, cleared by the reopen.", + "type": "string" + }, + "routed_to": { + "description": "The gc.routed_to target the bead stays routed to after the reopen, when set.", + "type": "string" + } + }, + "required": [ + "bead_id" + ], + "type": "object" + }, "BeadDepsResponse": { "additionalProperties": false, "properties": { @@ -2203,6 +2224,9 @@ { "$ref": "#/components/schemas/BeadClaimRejectedPayload" }, + { + "$ref": "#/components/schemas/BeadDeadAssigneeReopenedPayload" + }, { "$ref": "#/components/schemas/BeadEventPayload" }, @@ -8234,6 +8258,7 @@ "bead.claim_rejected": "#/components/schemas/TypedEventStreamEnvelopeBeadClaimRejected", "bead.closed": "#/components/schemas/TypedEventStreamEnvelopeBeadClosed", "bead.created": "#/components/schemas/TypedEventStreamEnvelopeBeadCreated", + "bead.dead_assignee_reopened": "#/components/schemas/TypedEventStreamEnvelopeBeadDeadAssigneeReopened", "bead.deleted": "#/components/schemas/TypedEventStreamEnvelopeBeadDeleted", "bead.updated": "#/components/schemas/TypedEventStreamEnvelopeBeadUpdated", "bead.worktree.reap_skipped": "#/components/schemas/TypedEventStreamEnvelopeBeadWorktreeReapSkipped", @@ -8316,6 +8341,9 @@ { "$ref": "#/components/schemas/TypedEventStreamEnvelopeBeadCreated" }, + { + "$ref": "#/components/schemas/TypedEventStreamEnvelopeBeadDeadAssigneeReopened" + }, { "$ref": "#/components/schemas/TypedEventStreamEnvelopeBeadDeleted" }, @@ -8682,6 +8710,57 @@ "title": "TypedEventStreamEnvelope bead.created", "type": "object" }, + "TypedEventStreamEnvelopeBeadDeadAssigneeReopened": { + "additionalProperties": false, + "properties": { + "actor": { + "type": "string" + }, + "message": { + "type": "string" + }, + "payload": { + "$ref": "#/components/schemas/BeadDeadAssigneeReopenedPayload" + }, + "run_id": { + "type": "string" + }, + "seq": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "session_id": { + "type": "string" + }, + "step_id": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "ts": { + "format": "date-time", + "type": "string" + }, + "type": { + "const": "bead.dead_assignee_reopened", + "type": "string" + }, + "workflow": { + "$ref": "#/components/schemas/WorkflowEventProjection" + } + }, + "required": [ + "seq", + "type", + "ts", + "actor", + "payload" + ], + "title": "TypedEventStreamEnvelope bead.dead_assignee_reopened", + "type": "object" + }, "TypedEventStreamEnvelopeBeadDeleted": { "additionalProperties": false, "properties": { @@ -9350,6 +9429,7 @@ "bead.worktree.reaped", "bead.worktree.reap_skipped", "bead.claim_rejected", + "bead.dead_assignee_reopened", "mail.sent", "mail.read", "mail.archived", @@ -12332,6 +12412,7 @@ "bead.claim_rejected": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadClaimRejected", "bead.closed": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadClosed", "bead.created": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadCreated", + "bead.dead_assignee_reopened": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened", "bead.deleted": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadDeleted", "bead.updated": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadUpdated", "bead.worktree.reap_skipped": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadWorktreeReapSkipped", @@ -12414,6 +12495,9 @@ { "$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadCreated" }, + { + "$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened" + }, { "$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeBeadDeleted" }, @@ -12792,6 +12876,61 @@ "title": "TypedTaggedEventStreamEnvelope bead.created", "type": "object" }, + "TypedTaggedEventStreamEnvelopeBeadDeadAssigneeReopened": { + "additionalProperties": false, + "properties": { + "actor": { + "type": "string" + }, + "city": { + "type": "string" + }, + "message": { + "type": "string" + }, + "payload": { + "$ref": "#/components/schemas/BeadDeadAssigneeReopenedPayload" + }, + "run_id": { + "type": "string" + }, + "seq": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "session_id": { + "type": "string" + }, + "step_id": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "ts": { + "format": "date-time", + "type": "string" + }, + "type": { + "const": "bead.dead_assignee_reopened", + "type": "string" + }, + "workflow": { + "$ref": "#/components/schemas/WorkflowEventProjection" + } + }, + "required": [ + "seq", + "type", + "ts", + "actor", + "payload", + "city" + ], + "title": "TypedTaggedEventStreamEnvelope bead.dead_assignee_reopened", + "type": "object" + }, "TypedTaggedEventStreamEnvelopeBeadDeleted": { "additionalProperties": false, "properties": { @@ -13511,6 +13650,7 @@ "bead.worktree.reaped", "bead.worktree.reap_skipped", "bead.claim_rejected", + "bead.dead_assignee_reopened", "mail.sent", "mail.read", "mail.archived", diff --git a/internal/events/events.go b/internal/events/events.go index b3fbb012f3..ac37216811 100644 --- a/internal/events/events.go +++ b/internal/events/events.go @@ -31,18 +31,27 @@ const ( // an idempotent no-op rather than fanning out a second concurrent claim. // Turns the otherwise-silent lost-claim race (RCA gc-typpc: one bead, four // concurrent polecat claims) into an observable signal. ADR-0009. - BeadClaimRejected = "bead.claim_rejected" - MailSent = "mail.sent" - MailRead = "mail.read" - MailArchived = "mail.archived" - MailMarkedRead = "mail.marked_read" - MailMarkedUnread = "mail.marked_unread" - MailReplied = "mail.replied" - MailDeleted = "mail.deleted" - SessionDraining = "session.draining" - SessionUndrained = "session.undrained" - SessionQuarantined = "session.quarantined" - SessionIdleKilled = "session.idle_killed" + BeadClaimRejected = "bead.claim_rejected" + // BeadDeadAssigneeReopened fires when the reconciler reopens a routed work + // bead whose assignee resolves to no open session bead — the owning session + // closed/retired while the bead stayed assigned, leaving it open+routed but + // invisible to every claim probe (pool tier and demand require --unassigned; + // the hook requires an empty assignee). releaseOrphanedPoolAssignments clears + // the dead assignee so the pool can reclaim it; this event turns that + // otherwise-silent repair into an observable signal (mirrors the + // bead.claim_rejected shape). + BeadDeadAssigneeReopened = "bead.dead_assignee_reopened" + MailSent = "mail.sent" + MailRead = "mail.read" + MailArchived = "mail.archived" + MailMarkedRead = "mail.marked_read" + MailMarkedUnread = "mail.marked_unread" + MailReplied = "mail.replied" + MailDeleted = "mail.deleted" + SessionDraining = "session.draining" + SessionUndrained = "session.undrained" + SessionQuarantined = "session.quarantined" + SessionIdleKilled = "session.idle_killed" // SessionMaxAgeKilled fires when the controller preemptively restarts a // long-running session because its wall-clock age exceeded the agent's // max_session_age threshold. Motivating case: provider SDKs that cache @@ -221,6 +230,7 @@ var KnownEventTypes = []string{ BeadCreated, BeadClosed, BeadDeleted, BeadUpdated, BeadWorktreeReaped, BeadWorktreeReapSkipped, BeadClaimRejected, + BeadDeadAssigneeReopened, MailSent, MailRead, MailArchived, MailMarkedRead, MailMarkedUnread, MailReplied, MailDeleted, ConvoyCreated, ConvoyClosed, From b051fd791eaaebe4aa6049e55de9c29057477776 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 8 Jul 2026 18:03:40 -0700 Subject: [PATCH 011/225] simplify(S32): RetryHandler delegates to CreateHandler (fixes live trigger-config loss) (#4029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What this does Lands **S32** — two commits on the retry/create control path: 1. **RetryHandler delegates to CreateHandler** via `CreateParams.RetrySource` (`internal/convergence`), collapsing the duplicated retry-vs-create bead construction into one path. **Intended behavior fix:** a retried bead that carries trigger config now respects the trigger instead of dropping it. 2. **Extract `processAttemptControl` behind a 3-method strategy seam** (`internal/dispatch/control.go`), unifying the attempt-control dispatch. New tests: `internal/convergence/retry_test.go`, `internal/dispatch/control_test.go`. ## Gates - `go build ./internal/convergence ./internal/dispatch ./cmd/gc` — pass - `go vet ./internal/convergence ./internal/dispatch` — pass - `go test ./internal/convergence ./internal/dispatch` — pass ## Review verdict LAND via label PR (`status/needs-review-auto`) — behavior change (intended fix) on the retry/create control path warrants the auto-review pass. Optional nits deliberately **not** folded (left for auto-review): derive `RetryResult.Iteration` / `FirstWispID` from the actual create outcome; wrap CreateHandler validation errors with the source bead ID. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- internal/convergence/create.go | 23 +- internal/convergence/retry.go | 126 +++-------- internal/convergence/retry_test.go | 38 ++++ internal/dispatch/control.go | 330 +++++++++++++++-------------- internal/dispatch/control_test.go | 176 +++++++++++++++ 5 files changed, 446 insertions(+), 247 deletions(-) diff --git a/internal/convergence/create.go b/internal/convergence/create.go index 05b5753c4e..c7584b6d95 100644 --- a/internal/convergence/create.go +++ b/internal/convergence/create.go @@ -29,6 +29,11 @@ type CreateParams struct { // whichever store the handler is bound to; Rig is persisted as // metadata so status/list and audit can report the owning scope. Rig string + // RetrySource, when non-empty, marks this loop as a retry of a + // terminated source loop. It changes the partial-create rollback close + // reason and stamps FieldRetrySource metadata plus the retry_source + // event payload. Empty means a fresh (non-retry) create. + RetrySource string } // CreateResult holds the outcome of creating a convergence loop. @@ -88,9 +93,13 @@ func (h *Handler) CreateHandler(_ context.Context, params CreateParams) (CreateR // closeBead terminates the root bead on partial-create failure so the // reconciler does not try to resume an incomplete convergence loop. + closeReason := CloseReasonCreateRollback + if params.RetrySource != "" { + closeReason = CloseReasonRetryRollback + } closeBead := func(cause error) error { _ = h.Store.SetMetadata(beadID, FieldState, StateTerminated) - _ = h.Store.CloseBead(beadID, CloseReasonCreateRollback) + _ = h.Store.CloseBead(beadID, closeReason) return cause } @@ -114,12 +123,22 @@ func (h *Handler) CreateHandler(_ context.Context, params CreateParams) (CreateR {FieldTrigger, params.Trigger}, {FieldTriggerCondition, params.TriggerCondition}, } + if params.RetrySource != "" { + metaWrites = append(metaWrites, struct{ key, value string }{FieldRetrySource, params.RetrySource}) + } for _, mw := range metaWrites { if err := h.Store.SetMetadata(beadID, mw.key, mw.value); err != nil { return CreateResult{}, closeBead(fmt.Errorf("setting %s on convergence bead: %w", mw.key, err)) } } + // retrySource is stamped on the created event when this is a retry so + // downstream observers can trace the lineage to the source loop. + var retrySource *string + if params.RetrySource != "" { + retrySource = ¶ms.RetrySource + } + // Step 3: Set template variables. for k, v := range params.Vars { if err := h.Store.SetMetadata(beadID, VarPrefix+k, v); err != nil { @@ -143,6 +162,7 @@ func (h *Handler) CreateHandler(_ context.Context, params CreateParams) (CreateR GateMode: params.GateMode, MaxIterations: params.MaxIterations, Title: title, + RetrySource: retrySource, } h.emitEvent(EventCreated, EventIDCreated(beadID), beadID, createdPayload) return CreateResult{BeadID: beadID}, nil @@ -176,6 +196,7 @@ func (h *Handler) CreateHandler(_ context.Context, params CreateParams) (CreateR MaxIterations: params.MaxIterations, Title: title, FirstWispID: firstWispID, + RetrySource: retrySource, } h.emitEvent(EventCreated, EventIDCreated(beadID), beadID, createdPayload) diff --git a/internal/convergence/retry.go b/internal/convergence/retry.go index 202208e8c5..865417c475 100644 --- a/internal/convergence/retry.go +++ b/internal/convergence/retry.go @@ -18,7 +18,7 @@ type RetryResult struct { // // The source bead must be in terminated state with a terminal_reason // other than "approved" (approved loops cannot be retried). -func (h *Handler) RetryHandler(_ context.Context, sourceBeadID, _ string, maxIterations int) (RetryResult, error) { +func (h *Handler) RetryHandler(ctx context.Context, sourceBeadID, _ string, maxIterations int) (RetryResult, error) { // Step 1: Read source bead metadata. meta, err := h.Store.GetMetadata(sourceBeadID) if err != nil { @@ -41,107 +41,49 @@ func (h *Handler) RetryHandler(_ context.Context, sourceBeadID, _ string, maxIte ) } - // Step 4: Read source configuration. - formula := meta[FieldFormula] - target := meta[FieldTarget] - gateMode := meta[FieldGateMode] - gateCondition := meta[FieldGateCondition] - gateTimeout := meta[FieldGateTimeout] - gateTimeoutAction := meta[FieldGateTimeoutAction] - cityPath := meta[FieldCityPath] - rig := meta[FieldRig] - evaluatePrompt := meta[FieldEvaluatePrompt] - vars := ExtractVars(meta) - - // Step 4b: Validate gate config from source bead before creating state. + // Step 4: Validate gate config from source bead before creating state. + // CreateHandler re-validates, but doing it here first preserves the + // source-scoped error message and the "no bead created on invalid source" + // guarantee that retry callers rely on. gateMeta := map[string]string{ - FieldGateMode: gateMode, - FieldGateCondition: gateCondition, - FieldGateTimeout: gateTimeout, - FieldGateTimeoutAction: gateTimeoutAction, + FieldGateMode: meta[FieldGateMode], + FieldGateCondition: meta[FieldGateCondition], + FieldGateTimeout: meta[FieldGateTimeout], + FieldGateTimeoutAction: meta[FieldGateTimeoutAction], } if _, err := ParseGateConfig(gateMeta); err != nil { return RetryResult{}, fmt.Errorf("source bead %q has invalid gate config: %w", sourceBeadID, err) } - // Step 5: Create new root bead. - title := "Retry of " + sourceBeadID - newBeadID, err := h.Store.CreateConvergenceBead(title) - if err != nil { - return RetryResult{}, fmt.Errorf("creating convergence bead: %w", err) - } - - // closeBead terminates the root bead on partial-create failure so the - // reconciler does not try to resume an incomplete convergence loop. - closeBead := func(cause error) error { - _ = h.Store.SetMetadata(newBeadID, FieldState, StateTerminated) - _ = h.Store.CloseBead(newBeadID, CloseReasonRetryRollback) - return cause - } - - // Mark as creating so the reconciler can detect partial creation. - if err := h.Store.SetMetadata(newBeadID, FieldState, StateCreating); err != nil { - return RetryResult{}, closeBead(fmt.Errorf("setting creating state: %w", err)) - } - - // Step 6: Set metadata on new bead. - metaWrites := []struct{ key, value string }{ - {FieldFormula, formula}, - {FieldTarget, target}, - {FieldGateMode, gateMode}, - {FieldGateCondition, gateCondition}, - {FieldGateTimeout, gateTimeout}, - {FieldGateTimeoutAction, gateTimeoutAction}, - {FieldMaxIterations, EncodeInt(maxIterations)}, - {FieldCityPath, cityPath}, - {FieldRig, rig}, - {FieldEvaluatePrompt, evaluatePrompt}, - {FieldRetrySource, sourceBeadID}, - {FieldState, StateActive}, - } - for _, mw := range metaWrites { - if err := h.Store.SetMetadata(newBeadID, mw.key, mw.value); err != nil { - return RetryResult{}, closeBead(fmt.Errorf("setting %s on new bead: %w", mw.key, err)) - } - } - - // Step 7: Copy template variables. - for k, v := range vars { - if err := h.Store.SetMetadata(newBeadID, VarPrefix+k, v); err != nil { - return RetryResult{}, closeBead(fmt.Errorf("copying var %q to new bead: %w", k, err)) - } - } - - // Step 8: Pour first wisp. - firstKey := IdempotencyKey(newBeadID, 1) - firstWispID, err := h.Store.PourWisp(newBeadID, formula, firstKey, vars, evaluatePrompt) + // Step 5: Map the source configuration onto CreateParams and delegate to + // CreateHandler. This is the single create path: bead create, rollback, + // StateCreating marker, metadata, first-wisp pour, and the created event + // all live in CreateHandler. Trigger fields carry forward so a retried + // trigger-gated loop keeps its entry gate (previously dropped here). + result, err := h.CreateHandler(ctx, CreateParams{ + Formula: meta[FieldFormula], + Target: meta[FieldTarget], + MaxIterations: maxIterations, + GateMode: meta[FieldGateMode], + GateCondition: meta[FieldGateCondition], + GateTimeout: meta[FieldGateTimeout], + GateTimeoutAction: meta[FieldGateTimeoutAction], + Title: "Retry of " + sourceBeadID, + Vars: ExtractVars(meta), + CityPath: meta[FieldCityPath], + EvaluatePrompt: meta[FieldEvaluatePrompt], + Trigger: meta[FieldTrigger], + TriggerCondition: meta[FieldTriggerCondition], + Rig: meta[FieldRig], + RetrySource: sourceBeadID, + }) if err != nil { - return RetryResult{}, closeBead(fmt.Errorf("pouring first wisp for retry bead %q: %w", newBeadID, err)) - } - - // Step 9: Set active_wisp and iteration counter. - if err := h.Store.SetMetadata(newBeadID, FieldActiveWisp, firstWispID); err != nil { - return RetryResult{}, closeBead(fmt.Errorf("setting active wisp on new bead: %w", err)) - } - if err := h.Store.SetMetadata(newBeadID, FieldIteration, EncodeInt(1)); err != nil { - return RetryResult{}, closeBead(fmt.Errorf("setting iteration on new bead: %w", err)) - } - - // Step 10: Emit ConvergenceCreated event with retry_source. - createdPayload := CreatedPayload{ - Formula: formula, - Target: target, - GateMode: gateMode, - MaxIterations: maxIterations, - Title: title, - FirstWispID: firstWispID, - RetrySource: &sourceBeadID, + return RetryResult{}, err } - h.emitEvent(EventCreated, EventIDCreated(newBeadID), newBeadID, createdPayload) return RetryResult{ - NewBeadID: newBeadID, - FirstWispID: firstWispID, + NewBeadID: result.BeadID, + FirstWispID: result.FirstWispID, Iteration: 1, }, nil } diff --git a/internal/convergence/retry_test.go b/internal/convergence/retry_test.go index 61127b2f62..008b71fec2 100644 --- a/internal/convergence/retry_test.go +++ b/internal/convergence/retry_test.go @@ -238,6 +238,44 @@ func TestRetryHandler_CopiesConfig(t *testing.T) { } } +// TestRetryHandler_CarriesTriggerForward is the regression guard for the +// live trigger-config-loss drift: before RetryHandler delegated to +// CreateHandler it silently dropped the trigger/trigger_condition fields, so +// retrying a trigger-gated loop produced a non-trigger-gated loop that poured +// its first wisp immediately. Delegation must carry the trigger config forward +// AND honor CreateHandler's trigger entry gate (waiting_trigger, no first wisp). +func TestRetryHandler_CarriesTriggerForward(t *testing.T) { + handler, store, _ := setupTerminatedHandler(t, TerminalStopped, map[string]string{ + FieldTrigger: TriggerEvent, + FieldTriggerCondition: "/path/to/trigger.sh", + }) + + result, err := handler.RetryHandler(context.Background(), "source-1", "alice", 10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + meta, _ := store.GetMetadata(result.NewBeadID) + if meta[FieldTrigger] != TriggerEvent { + t.Errorf("trigger = %q, want %q (trigger config must carry forward on retry)", meta[FieldTrigger], TriggerEvent) + } + if meta[FieldTriggerCondition] != "/path/to/trigger.sh" { + t.Errorf("trigger_condition = %q, want %q", meta[FieldTriggerCondition], "/path/to/trigger.sh") + } + // The trigger entry gate must defer the first pour: state waiting_trigger, + // iteration 0, no first wisp — exactly what CreateHandler does for a fresh + // trigger-gated loop. + if meta[FieldState] != StateWaitingTrigger { + t.Errorf("state = %q, want %q (trigger entry gate must be honored on retry)", meta[FieldState], StateWaitingTrigger) + } + if meta[FieldIteration] != "0" { + t.Errorf("iteration = %q, want %q", meta[FieldIteration], "0") + } + if result.FirstWispID != "" { + t.Errorf("FirstWispID = %q, want empty (trigger-gated loop defers first pour)", result.FirstWispID) + } +} + func TestRetryHandler_SetsRetrySource(t *testing.T) { handler, store, _ := setupTerminatedHandler(t, TerminalStopped, nil) diff --git a/internal/dispatch/control.go b/internal/dispatch/control.go index be8ca18c13..f095a9ce8c 100644 --- a/internal/dispatch/control.go +++ b/internal/dispatch/control.go @@ -19,66 +19,132 @@ import ( "github.com/gastownhall/gascity/internal/session" ) +// attemptDisposition is the normalized outcome of a closed attempt/iteration, +// shared by the retry and ralph control loops. +type attemptDisposition int + +const ( + // attemptPass closes the control as passed. + attemptPass attemptDisposition = iota + // attemptHardFail closes the control as a terminal hard failure regardless + // of attempts remaining (only the retry classifier produces this). + attemptHardFail + // attemptContinue spawns the next attempt when attempts remain, or disposes + // of the exhausted control via the strategy when max_attempts is reached. + attemptContinue +) + +// attemptEvaluation is the strategy-produced classification of a closed +// attempt/iteration bead: its disposition plus the values recorded in the +// attempt log and (for hard/exhaust closures) the failure reason. +type attemptEvaluation struct { + disposition attemptDisposition + logOutcome string // value recorded in the attempt log + logDetail string // detail recorded in the attempt log (reason/stderr) + reason string // failure reason stamped on terminal metadata +} + +// controlAttemptStrategy is the per-kind seam over the shared attempt loop. +// The two live implementations (retry, ralph) differ only in how they classify +// a closed attempt, what extra metadata a pass carries, and how an exhausted +// attempt is disposed. kind/subjectNoun/missingNoun carry the control-kind +// trace and error wording (control kinds, not role names). +type controlAttemptStrategy struct { + kind string // "retry" | "ralph" — trace text only + subjectNoun string // "attempt" | "iteration" — error/trace text + missingNoun string // "no attempt found" | "no iteration found" + evaluate func(store beads.Store, bead, attempt beads.Bead, attemptNum int, opts ProcessOptions) (attemptEvaluation, error) + onPass func(closeMetadata map[string]string, attempt beads.Bead) + exhaust func(store beads.Store, beadID string, attemptNum int, reason, attemptLog string) (ControlResult, error) +} + // processRetryControl handles a retry control bead when it becomes ready // (its blocking dep on the latest attempt has resolved). func processRetryControl(store beads.Store, bead beads.Bead, opts ProcessOptions) (ControlResult, error) { - maxAttempts, err := strconv.Atoi(bead.Metadata[beadmeta.MaxAttemptsMetadataKey]) - if err != nil || maxAttempts < 1 { - return ControlResult{}, fmt.Errorf("%s: invalid gc.max_attempts %q", bead.ID, bead.Metadata[beadmeta.MaxAttemptsMetadataKey]) - } onExhausted := bead.Metadata[beadmeta.OnExhaustedMetadataKey] if onExhausted == "" { onExhausted = beadmeta.DispositionHardFail } + strategy := controlAttemptStrategy{ + kind: "retry", + subjectNoun: "attempt", + missingNoun: "no attempt found", + evaluate: evaluateRetryAttempt, + onPass: func(closeMetadata map[string]string, attempt beads.Bead) { + copyNonGCMetadata(closeMetadata, attempt.Metadata) + }, + exhaust: func(store beads.Store, beadID string, attemptNum int, reason, attemptLog string) (ControlResult, error) { + return handleRetryExhaustion(store, beadID, attemptNum, reason, onExhausted, attemptLog) + }, + } + return processAttemptControl(store, bead, opts, strategy) +} + +// processRalphControl handles a ralph control bead when it becomes ready. +func processRalphControl(store beads.Store, bead beads.Bead, opts ProcessOptions) (ControlResult, error) { + strategy := controlAttemptStrategy{ + kind: "ralph", + subjectNoun: "iteration", + missingNoun: "no iteration found", + evaluate: evaluateRalphIteration, + exhaust: func(store beads.Store, beadID string, iterationNum int, _, attemptLog string) (ControlResult, error) { + closeMetadata := map[string]string{ + beadmeta.AttemptLogMetadataKey: attemptLog, + beadmeta.OutcomeMetadataKey: beadmeta.OutcomeFail, + beadmeta.FailedAttemptMetadataKey: strconv.Itoa(iterationNum), + } + clearControllerSpawnErrorMetadata(closeMetadata) + if err := updateMetadataAndClose(store, beadID, closeMetadata); err != nil { + return ControlResult{}, fmt.Errorf("%s: closing exhausted: %w", beadID, err) + } + return ControlResult{Processed: true, Action: "fail"}, nil + }, + } + return processAttemptControl(store, bead, opts, strategy) +} + +// processAttemptControl is the shared retry/ralph control loop: parse +// max_attempts, find the latest attempt, quarantine a malformed graph, drive a +// pending attempt to convergence, then classify the closed attempt via the +// strategy and pass / hard-fail / spawn-next / exhaust accordingly. The three +// per-kind seams live in controlAttemptStrategy. +func processAttemptControl(store beads.Store, bead beads.Bead, opts ProcessOptions, strategy controlAttemptStrategy) (ControlResult, error) { + maxAttempts, err := strconv.Atoi(bead.Metadata[beadmeta.MaxAttemptsMetadataKey]) + if err != nil || maxAttempts < 1 { + return ControlResult{}, fmt.Errorf("%s: invalid gc.max_attempts %q", bead.ID, bead.Metadata[beadmeta.MaxAttemptsMetadataKey]) + } // Find the most recent attempt. attempt, err := findLatestAttempt(store, bead) if err != nil { - return ControlResult{}, fmt.Errorf("%s: finding latest attempt: %w", bead.ID, err) + return ControlResult{}, fmt.Errorf("%s: finding latest %s: %w", bead.ID, strategy.subjectNoun, err) } if attempt.ID == "" { - // A retry control with no attempt sub-DAG cannot become valid by - // waiting — the graph is malformed (missing seed or a seed attach - // marked molecule_failed). Classify for the dispatcher quarantine - // instead of fataling the serve loop. See gastownhall/gascity#2798. - opts.tracef("process-control bead=%s kind=retry quarantine reason=no_attempt_found root=%s", - bead.ID, bead.Metadata[beadmeta.RootBeadIDMetadataKey]) - return ControlResult{}, fmt.Errorf("%w: %s: no attempt found", ErrControlGraphMalformed, bead.ID) + // A control with no attempt sub-DAG cannot become valid by waiting — + // the graph is malformed (missing seed or a seed attach marked + // molecule_failed). Classify for the dispatcher quarantine instead of + // fataling the serve loop, which crash-looped all dispatch for the rig. + // See gastownhall/gascity#2798. + opts.tracef("process-control bead=%s kind=%s quarantine reason=no_%s_found root=%s", + bead.ID, strategy.kind, strategy.subjectNoun, bead.Metadata[beadmeta.RootBeadIDMetadataKey]) + return ControlResult{}, fmt.Errorf("%w: %s: %s", ErrControlGraphMalformed, bead.ID, strategy.missingNoun) } if attempt.Status != "closed" { - if err := ensureBlockingDependency(store, bead.ID, attempt.ID); err != nil { - if controllerSpawnBoundaryPending(store, bead.ID, err, opts) { - return ControlResult{}, ErrControlPending - } - return ControlResult{}, fmt.Errorf("%s: blocking on pending attempt %s: %w", bead.ID, attempt.ID, err) - } - if err := syncControlEpochToAttempt(store, bead, attempt); err != nil { - if controllerSpawnBoundaryPending(store, bead.ID, err, opts) { - return ControlResult{}, ErrControlPending - } - return ControlResult{}, fmt.Errorf("%s: advancing recovered attempt epoch for %s: %w", bead.ID, attempt.ID, err) - } - if err := closeGeneratedSpecBeadsForAttempt(store, bead, attempt); err != nil { - if controllerSpawnBoundaryPending(store, bead.ID, err, opts) { - return ControlResult{}, ErrControlPending - } - return ControlResult{}, fmt.Errorf("%s: closing generated spec beads for pending attempt %s: %w", bead.ID, attempt.ID, err) - } - return ControlResult{}, ErrControlPending + return ensurePendingAttemptConverges(store, bead, attempt, strategy, opts) } attemptNum, _ := strconv.Atoi(attempt.Metadata[beadmeta.AttemptMetadataKey]) - result, err := classifyRetryAttemptWithPostconditions(store, attempt, opts) + eval, err := strategy.evaluate(store, bead, attempt, attemptNum, opts) if err != nil { - return ControlResult{}, fmt.Errorf("%s: evaluating retry postconditions for %s: %w", bead.ID, attempt.ID, err) + return ControlResult{}, err } - attemptLog, err := appendAttemptLogValue(bead.Metadata[beadmeta.AttemptLogMetadataKey], attemptNum, result.Outcome, result.Reason) + attemptLog, err := appendAttemptLogValue(bead.Metadata[beadmeta.AttemptLogMetadataKey], attemptNum, eval.logOutcome, eval.logDetail) if err != nil { return ControlResult{}, fmt.Errorf("%s: recording attempt log: %w", bead.ID, err) } - switch result.Outcome { - case "pass": + switch eval.disposition { + case attemptPass: closeMetadata := map[string]string{ beadmeta.AttemptLogMetadataKey: attemptLog, beadmeta.OutcomeMetadataKey: beadmeta.OutcomePass, @@ -87,7 +153,9 @@ func processRetryControl(store beads.Store, bead beads.Bead, opts ProcessOptions if outputJSON := attempt.Metadata[beadmeta.OutputJSONMetadataKey]; outputJSON != "" { closeMetadata[beadmeta.OutputJSONMetadataKey] = outputJSON } - copyNonGCMetadata(closeMetadata, attempt.Metadata) + if strategy.onPass != nil { + strategy.onPass(closeMetadata, attempt) + } if err := updateMetadataAndClose(store, bead.ID, closeMetadata); err != nil { return ControlResult{}, fmt.Errorf("%s: closing passed: %w", bead.ID, err) } @@ -97,13 +165,13 @@ func processRetryControl(store beads.Store, bead beads.Bead, opts ProcessOptions } return ControlResult{Processed: true, Action: "pass", Skipped: scopeResult.Skipped}, nil - case "hard": + case attemptHardFail: closeMetadata := map[string]string{ beadmeta.AttemptLogMetadataKey: attemptLog, beadmeta.OutcomeMetadataKey: beadmeta.OutcomeFail, beadmeta.FailedAttemptMetadataKey: strconv.Itoa(attemptNum), beadmeta.FailureClassMetadataKey: beadmeta.FailureClassHard, - beadmeta.FailureReasonMetadataKey: result.Reason, + beadmeta.FailureReasonMetadataKey: eval.reason, beadmeta.FinalDispositionMetadataKey: beadmeta.DispositionHardFail, } clearControllerSpawnErrorMetadata(closeMetadata) @@ -116,9 +184,9 @@ func processRetryControl(store beads.Store, bead beads.Bead, opts ProcessOptions } return ControlResult{Processed: true, Action: "hard-fail", Skipped: scopeResult.Skipped}, nil - case "transient": + case attemptContinue: if attemptNum >= maxAttempts { - exhaustedResult, err := handleRetryExhaustion(store, bead.ID, attemptNum, result.Reason, onExhausted, attemptLog) + exhaustedResult, err := strategy.exhaust(store, bead.ID, attemptNum, eval.reason, attemptLog) if err != nil { return ControlResult{}, err } @@ -144,141 +212,95 @@ func processRetryControl(store beads.Store, bead beads.Bead, opts ProcessOptions if markControllerSpawnError(store, bead.ID, err, opts) { return ControlResult{}, ErrControlPending } - return ControlResult{}, fmt.Errorf("%s: spawning attempt %d: %w", bead.ID, nextAttempt, err) + return ControlResult{}, fmt.Errorf("%s: spawning %s %d: %w", bead.ID, strategy.subjectNoun, nextAttempt, err) } return ControlResult{Processed: true, Action: "retry", Created: 1}, nil default: - return ControlResult{}, fmt.Errorf("%s: unsupported outcome %q", bead.ID, result.Outcome) + return ControlResult{}, fmt.Errorf("%s: unsupported attempt disposition", bead.ID) } } -// processRalphControl handles a ralph control bead when it becomes ready. -func processRalphControl(store beads.Store, bead beads.Bead, opts ProcessOptions) (ControlResult, error) { - maxAttempts, err := strconv.Atoi(bead.Metadata[beadmeta.MaxAttemptsMetadataKey]) - if err != nil || maxAttempts < 1 { - return ControlResult{}, fmt.Errorf("%s: invalid gc.max_attempts %q", bead.ID, bead.Metadata[beadmeta.MaxAttemptsMetadataKey]) - } - - // Find the most recent iteration. - iteration, err := findLatestAttempt(store, bead) - if err != nil { - return ControlResult{}, fmt.Errorf("%s: finding latest iteration: %w", bead.ID, err) - } - if iteration.ID == "" { - // A ralph control with no iteration sub-DAG cannot become valid by - // waiting — the graph is malformed (missing first-iteration seed or - // a seed attach marked molecule_failed). Classify for the dispatcher - // quarantine instead of fataling the serve loop, which crash-looped - // all dispatch for the rig. See gastownhall/gascity#2798. - opts.tracef("process-control bead=%s kind=ralph quarantine reason=no_iteration_found root=%s", - bead.ID, bead.Metadata[beadmeta.RootBeadIDMetadataKey]) - return ControlResult{}, fmt.Errorf("%w: %s: no iteration found", ErrControlGraphMalformed, bead.ID) - } - if iteration.Status != "closed" { - if err := ensureBlockingDependency(store, bead.ID, iteration.ID); err != nil { - if controllerSpawnBoundaryPending(store, bead.ID, err, opts) { - return ControlResult{}, ErrControlPending - } - return ControlResult{}, fmt.Errorf("%s: blocking on pending iteration %s: %w", bead.ID, iteration.ID, err) +// ensurePendingAttemptConverges drives a not-yet-closed attempt toward +// convergence: it re-adds the blocking dep, syncs the control epoch to a +// recovered attempt, and closes any generated spec beads, returning +// ErrControlPending. Each store boundary error is classified through the +// controller spawn boundary so transient failures stay open for retry. +func ensurePendingAttemptConverges(store beads.Store, bead, attempt beads.Bead, strategy controlAttemptStrategy, opts ProcessOptions) (ControlResult, error) { + if err := ensureBlockingDependency(store, bead.ID, attempt.ID); err != nil { + if controllerSpawnBoundaryPending(store, bead.ID, err, opts) { + return ControlResult{}, ErrControlPending } - if err := syncControlEpochToAttempt(store, bead, iteration); err != nil { - if controllerSpawnBoundaryPending(store, bead.ID, err, opts) { - return ControlResult{}, ErrControlPending - } - return ControlResult{}, fmt.Errorf("%s: advancing recovered iteration epoch for %s: %w", bead.ID, iteration.ID, err) + return ControlResult{}, fmt.Errorf("%s: blocking on pending %s %s: %w", bead.ID, strategy.subjectNoun, attempt.ID, err) + } + if err := syncControlEpochToAttempt(store, bead, attempt); err != nil { + if controllerSpawnBoundaryPending(store, bead.ID, err, opts) { + return ControlResult{}, ErrControlPending } - if err := closeGeneratedSpecBeadsForAttempt(store, bead, iteration); err != nil { - if controllerSpawnBoundaryPending(store, bead.ID, err, opts) { - return ControlResult{}, ErrControlPending - } - return ControlResult{}, fmt.Errorf("%s: closing generated spec beads for pending iteration %s: %w", bead.ID, iteration.ID, err) + return ControlResult{}, fmt.Errorf("%s: advancing recovered %s epoch for %s: %w", bead.ID, strategy.subjectNoun, attempt.ID, err) + } + if err := closeGeneratedSpecBeadsForAttempt(store, bead, attempt); err != nil { + if controllerSpawnBoundaryPending(store, bead.ID, err, opts) { + return ControlResult{}, ErrControlPending } - return ControlResult{}, ErrControlPending + return ControlResult{}, fmt.Errorf("%s: closing generated spec beads for pending %s %s: %w", bead.ID, strategy.subjectNoun, attempt.ID, err) } + return ControlResult{}, ErrControlPending +} - iterationNum, _ := strconv.Atoi(iteration.Metadata[beadmeta.AttemptMetadataKey]) - - // Propagate non-gc metadata from the iteration to the ralph control - // BEFORE running the check. This makes the iteration's output (e.g., - // review.verdict) visible on the ralph bead for check scripts that - // read $GC_BEAD_ID metadata. - if err := propagateRetrySubjectMetadata(store, bead.ID, iteration); err != nil { - return ControlResult{}, fmt.Errorf("%s: propagating iteration metadata: %w", bead.ID, err) - } - // Reload the bead after metadata propagation so the check sees updated values. - bead, err = store.Get(bead.ID) +// evaluateRetryAttempt classifies a closed retry attempt via its worker-result +// postconditions. classifyRetryAttempt only emits pass/hard/transient, so the +// default branch is defensive. +func evaluateRetryAttempt(store beads.Store, bead, attempt beads.Bead, _ int, opts ProcessOptions) (attemptEvaluation, error) { + result, err := classifyRetryAttemptWithPostconditions(store, attempt, opts) if err != nil { - return ControlResult{}, fmt.Errorf("%s: reloading after propagation: %w", bead.ID, err) + return attemptEvaluation{}, fmt.Errorf("%s: evaluating retry postconditions for %s: %w", bead.ID, attempt.ID, err) } + eval := attemptEvaluation{logOutcome: result.Outcome, logDetail: result.Reason, reason: result.Reason} + switch result.Outcome { + case "pass": + eval.disposition = attemptPass + case "hard": + eval.disposition = attemptHardFail + case "transient": + eval.disposition = attemptContinue + default: + return attemptEvaluation{}, fmt.Errorf("%s: unsupported outcome %q", bead.ID, result.Outcome) + } + return eval, nil +} - // Run check script. The control bead carries the check config (gc.check_path etc), - // and the iteration is the subject whose output is being checked. - checkResult, err := runRalphCheck(store, bead, iteration, iterationNum, opts) +// evaluateRalphIteration propagates the iteration's non-gc metadata onto the +// ralph control, reloads the control so the check sees the updated values, and +// runs the check script. A GatePass closes the control; anything else spawns +// the next iteration or exhausts. +func evaluateRalphIteration(store beads.Store, bead, iteration beads.Bead, iterationNum int, opts ProcessOptions) (attemptEvaluation, error) { + // Propagate non-gc metadata from the iteration to the ralph control BEFORE + // running the check. This makes the iteration's output (e.g., + // review.verdict) visible on the ralph bead for check scripts that read + // $GC_BEAD_ID metadata. + if err := propagateRetrySubjectMetadata(store, bead.ID, iteration); err != nil { + return attemptEvaluation{}, fmt.Errorf("%s: propagating iteration metadata: %w", bead.ID, err) + } + // Reload the control bead after propagation so the check sees updated values. + reloaded, err := store.Get(bead.ID) if err != nil { - return ControlResult{}, fmt.Errorf("%s: running check: %w", bead.ID, err) + return attemptEvaluation{}, fmt.Errorf("%s: reloading after propagation: %w", bead.ID, err) } - - attemptLog, err := appendAttemptLogValue(bead.Metadata[beadmeta.AttemptLogMetadataKey], iterationNum, checkResult.Outcome, checkResult.Stderr) + // The control bead carries the check config (gc.check_path etc), and the + // iteration is the subject whose output is being checked. + checkResult, err := runRalphCheck(store, reloaded, iteration, iterationNum, opts) if err != nil { - return ControlResult{}, fmt.Errorf("%s: recording attempt log: %w", bead.ID, err) + return attemptEvaluation{}, fmt.Errorf("%s: running check: %w", bead.ID, err) } - + eval := attemptEvaluation{logOutcome: checkResult.Outcome, logDetail: checkResult.Stderr} if checkResult.Outcome == convergence.GatePass { - closeMetadata := map[string]string{ - beadmeta.AttemptLogMetadataKey: attemptLog, - beadmeta.OutcomeMetadataKey: beadmeta.OutcomePass, - } - clearControllerSpawnErrorMetadata(closeMetadata) - if outputJSON := iteration.Metadata[beadmeta.OutputJSONMetadataKey]; outputJSON != "" { - closeMetadata[beadmeta.OutputJSONMetadataKey] = outputJSON - } - if err := updateMetadataAndClose(store, bead.ID, closeMetadata); err != nil { - return ControlResult{}, fmt.Errorf("%s: closing passed: %w", bead.ID, err) - } - scopeResult, err := reconcileClosedScopeMemberWithOptions(store, bead.ID, opts) - if err != nil { - return ControlResult{}, fmt.Errorf("%s: reconciling enclosing scope: %w", bead.ID, err) - } - return ControlResult{Processed: true, Action: "pass", Skipped: scopeResult.Skipped}, nil - } - - if iterationNum >= maxAttempts { - closeMetadata := map[string]string{ - beadmeta.AttemptLogMetadataKey: attemptLog, - beadmeta.OutcomeMetadataKey: beadmeta.OutcomeFail, - beadmeta.FailedAttemptMetadataKey: strconv.Itoa(iterationNum), - } - clearControllerSpawnErrorMetadata(closeMetadata) - if err := updateMetadataAndClose(store, bead.ID, closeMetadata); err != nil { - return ControlResult{}, fmt.Errorf("%s: closing exhausted: %w", bead.ID, err) - } - scopeResult, err := reconcileClosedScopeMemberWithOptions(store, bead.ID, opts) - if err != nil { - return ControlResult{}, fmt.Errorf("%s: reconciling enclosing scope: %w", bead.ID, err) - } - return ControlResult{Processed: true, Action: "fail", Skipped: scopeResult.Skipped}, nil - } - - // Spawn next iteration. - spawnMetadata := map[string]string{beadmeta.AttemptLogMetadataKey: attemptLog} - clearControllerSpawnErrorMetadata(spawnMetadata) - if err := store.SetMetadataBatch(bead.ID, spawnMetadata); err != nil { - if controllerSpawnBoundaryPending(store, bead.ID, err, opts) { - return ControlResult{}, ErrControlPending - } - return ControlResult{}, fmt.Errorf("%s: recording attempt log: %w", bead.ID, err) - } - nextIteration := iterationNum + 1 - if err := spawnNextAttempt(context.Background(), store, bead, nextIteration, opts); err != nil { - if markControllerSpawnError(store, bead.ID, err, opts) { - return ControlResult{}, ErrControlPending - } - return ControlResult{}, fmt.Errorf("%s: spawning iteration %d: %w", bead.ID, nextIteration, err) + eval.disposition = attemptPass + } else { + eval.disposition = attemptContinue } - - return ControlResult{Processed: true, Action: "retry", Created: 1}, nil + return eval, nil } func ensureBlockingDependency(store beads.Store, issueID, dependsOnID string) error { diff --git a/internal/dispatch/control_test.go b/internal/dispatch/control_test.go index 52d4dbea61..3462fee5ae 100644 --- a/internal/dispatch/control_test.go +++ b/internal/dispatch/control_test.go @@ -86,6 +86,182 @@ func TestProcessRetryControlPass(t *testing.T) { } } +// --------------------------------------------------------------------------- +// processAttemptControl shared-loop tests (fake evaluator) +// --------------------------------------------------------------------------- + +// setupAttemptControl builds a retry-shaped control bead with a single closed +// attempt whose gc.attempt is attemptNum, suitable for driving +// processAttemptControl with a scripted strategy. +func setupAttemptControl(t *testing.T, store beads.Store, maxAttempts, attemptNum int) beads.Bead { + t.Helper() + root := mustCreate(t, store, beads.Bead{ + Title: "workflow", + Metadata: map[string]string{"gc.kind": "workflow"}, + }) + control := mustCreate(t, store, beads.Bead{ + Title: "control", + Metadata: map[string]string{ + "gc.kind": "retry", + "gc.root_bead_id": root.ID, + "gc.step_ref": "mol-test.control", + "gc.step_id": "control", + "gc.max_attempts": strconv.Itoa(maxAttempts), + }, + }) + attempt := mustCreate(t, store, beads.Bead{ + Title: "attempt", + Metadata: map[string]string{ + "gc.root_bead_id": root.ID, + "gc.step_ref": fmt.Sprintf("mol-test.control.attempt.%d", attemptNum), + "gc.attempt": strconv.Itoa(attemptNum), + }, + }) + mustClose(t, store, attempt.ID) + mustDep(t, store, control.ID, attempt.ID, "blocks") + return mustGet(t, store, control.ID) +} + +func TestProcessAttemptControlPassInvokesOnPass(t *testing.T) { + t.Parallel() + store := beads.NewMemStore() + control := setupAttemptControl(t, store, 3, 1) + + onPassCalled := false + strategy := controlAttemptStrategy{ + kind: "retry", + subjectNoun: "attempt", + missingNoun: "no attempt found", + evaluate: func(_ beads.Store, _, _ beads.Bead, _ int, _ ProcessOptions) (attemptEvaluation, error) { + return attemptEvaluation{disposition: attemptPass, logOutcome: "pass"}, nil + }, + onPass: func(closeMetadata map[string]string, _ beads.Bead) { + onPassCalled = true + closeMetadata["fake.stamp"] = "yes" + }, + } + + result, err := processAttemptControl(store, control, ProcessOptions{}, strategy) + if err != nil { + t.Fatalf("processAttemptControl: %v", err) + } + if !result.Processed || result.Action != "pass" { + t.Fatalf("result = %+v, want processed pass", result) + } + if !onPassCalled { + t.Fatal("onPass was not invoked on the pass path") + } + after := mustGet(t, store, control.ID) + if after.Status != "closed" || after.Metadata["gc.outcome"] != "pass" { + t.Fatalf("control = %q/%q, want closed/pass", after.Status, after.Metadata["gc.outcome"]) + } + if after.Metadata["fake.stamp"] != "yes" { + t.Fatalf("onPass metadata not persisted: %v", after.Metadata) + } +} + +func TestProcessAttemptControlHardFailStampsTerminalMetadata(t *testing.T) { + t.Parallel() + store := beads.NewMemStore() + control := setupAttemptControl(t, store, 3, 1) + + strategy := controlAttemptStrategy{ + kind: "retry", + subjectNoun: "attempt", + missingNoun: "no attempt found", + evaluate: func(_ beads.Store, _, _ beads.Bead, _ int, _ ProcessOptions) (attemptEvaluation, error) { + return attemptEvaluation{disposition: attemptHardFail, logOutcome: "hard", reason: "boom"}, nil + }, + } + + result, err := processAttemptControl(store, control, ProcessOptions{}, strategy) + if err != nil { + t.Fatalf("processAttemptControl: %v", err) + } + if result.Action != "hard-fail" { + t.Fatalf("action = %q, want hard-fail", result.Action) + } + after := mustGet(t, store, control.ID) + if after.Metadata["gc.outcome"] != "fail" || + after.Metadata["gc.failure_class"] != beadmeta.FailureClassHard || + after.Metadata["gc.failure_reason"] != "boom" || + after.Metadata["gc.final_disposition"] != beadmeta.DispositionHardFail { + t.Fatalf("terminal metadata = %v, want hard-fail shape", after.Metadata) + } +} + +func TestProcessAttemptControlExhaustDelegatesToStrategy(t *testing.T) { + t.Parallel() + store := beads.NewMemStore() + control := setupAttemptControl(t, store, 2, 2) // attemptNum == maxAttempts + + var gotReason, gotLog string + strategy := controlAttemptStrategy{ + kind: "retry", + subjectNoun: "attempt", + missingNoun: "no attempt found", + evaluate: func(_ beads.Store, _, _ beads.Bead, _ int, _ ProcessOptions) (attemptEvaluation, error) { + return attemptEvaluation{disposition: attemptContinue, logOutcome: "transient", reason: "drained"}, nil + }, + exhaust: func(store beads.Store, beadID string, _ int, reason, attemptLog string) (ControlResult, error) { + gotReason, gotLog = reason, attemptLog + if err := updateMetadataAndClose(store, beadID, map[string]string{"gc.outcome": "fail"}); err != nil { + return ControlResult{}, err + } + return ControlResult{Processed: true, Action: "exhausted-sentinel"}, nil + }, + } + + result, err := processAttemptControl(store, control, ProcessOptions{}, strategy) + if err != nil { + t.Fatalf("processAttemptControl: %v", err) + } + if result.Action != "exhausted-sentinel" { + t.Fatalf("action = %q, want exhausted-sentinel (strategy.exhaust must own the disposition)", result.Action) + } + if gotReason != "drained" { + t.Fatalf("exhaust reason = %q, want drained", gotReason) + } + if gotLog == "" { + t.Fatal("exhaust received empty attempt log") + } +} + +func TestProcessAttemptControlMissingAttemptUsesStrategyNouns(t *testing.T) { + t.Parallel() + store := beads.NewMemStore() + root := mustCreate(t, store, beads.Bead{ + Title: "workflow", + Metadata: map[string]string{"gc.kind": "workflow"}, + }) + control := mustCreate(t, store, beads.Bead{ + Title: "control", + Metadata: map[string]string{ + "gc.kind": "ralph", + "gc.root_bead_id": root.ID, + "gc.max_attempts": "3", + }, + }) + + strategy := controlAttemptStrategy{ + kind: "ralph", + subjectNoun: "iteration", + missingNoun: "no iteration found", + evaluate: func(_ beads.Store, _, _ beads.Bead, _ int, _ ProcessOptions) (attemptEvaluation, error) { + t.Fatal("evaluate must not run when no attempt exists") + return attemptEvaluation{}, nil + }, + } + + _, err := processAttemptControl(store, mustGet(t, store, control.ID), ProcessOptions{}, strategy) + if !errors.Is(err, ErrControlGraphMalformed) { + t.Fatalf("err = %v, want ErrControlGraphMalformed", err) + } + if !strings.Contains(err.Error(), "no iteration found") { + t.Fatalf("err = %v, want strategy missingNoun 'no iteration found'", err) + } +} + func TestProcessRetryControlPassClosesWithSingleFinalMetadataUpdate(t *testing.T) { t.Parallel() base := beads.NewMemStore() From 3c0f74c03eaa834f7d8c5db7fd3df0774a3e568b Mon Sep 17 00:00:00 2001 From: Chris Sells Date: Wed, 8 Jul 2026 17:53:04 -0700 Subject: [PATCH 012/225] brew install CLI fix --- README.md | 2 +- RELEASING.md | 4 ++-- docs/getting-started/installation.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3628dbdff1..3da72a6125 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ heavy write load. Install from Homebrew: ```bash -brew install gastownhall/gascity/gascity +brew install gascity gc version ``` diff --git a/RELEASING.md b/RELEASING.md index ad6981da41..0066d45d27 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -9,7 +9,7 @@ | **Homebrew tap** (`gastownhall/gascity`) | `release.yml` writes an asset-based formula after archives upload | Yes | | **Homebrew core** (`Homebrew/homebrew-core`) | BrewTestBot autobump, once listed | Yes (~3h delay) | -The homebrew-core submission is [in progress](https://github.com/Homebrew/homebrew-core). Until it lands and is added to the autobump list, users install via `brew install gastownhall/gascity/gascity`. +The homebrew-core submission is [in progress](https://github.com/Homebrew/homebrew-core). Until it lands and is added to the autobump list, users install via `brew install gascity`. ## How to Release @@ -97,7 +97,7 @@ The release workflow automatically overwrites `Formula/gascity.rb` in the `gasto The tap formula installs prebuilt release assets, so users do not need Go or a source build: ```bash -brew install gastownhall/gascity/gascity +brew install gascity ``` The intended long-term user-facing Homebrew path is homebrew-core: diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 0ea39c66d0..7e33eb1454 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -44,7 +44,7 @@ The exact versions CI pins are in [`deps.env`](https://github.com/gastownhall/ga ## Homebrew (recommended) ```bash -brew install gastownhall/gascity/gascity +brew install gascity ``` This taps the `gastownhall/gascity` formula, downloads the matching `gc` From 37b8c9817b343b08f1583e6b60120f6505c71b04 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 8 Jul 2026 18:35:56 -0700 Subject: [PATCH 013/225] fix(dispatch): hard-fail ralph loops + supersession guard for hard aborts (#4035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two surgical fixes to how the control dispatcher consumes `gc.failure_class`, root-caused from two production adopt-pr-v2 molecule deaths in maintainer-city. ## FIX 1 — ralph treadmill (`internal/dispatch/ralph.go`) `processRalphCheck` branched only on GatePass vs `attempt >= maxAttempts` and never read `gc.failure_class`, so a HARD-class subject failure (e.g. `external_live_head_changed`) cloned attempts 1..N — a treadmill — before `abort_scope`-killing the molecule (observed on gascity#3943: pre-approval-ci iterations 1→5, all `external_live_head_changed`/hard, then abort). Now a subject that closed `gc.outcome=fail` with `gc.failure_class=hard` terminates the loop in **one attempt** (`Action "hard-fail"`), mirroring `processRetryEval`'s hard handling in `retry.go`. Empty/transient classes stay repairable and still clone up to `max_attempts`. ## FIX 2 — superseded hard abort outvotes passing iterations (`internal/dispatch/runtime.go`) `terminalAbortScopeFailure` applied the `isRetryAttemptSubject` supersession guard only in the default/unknown-class branch; the `hard` branch returned `true` unconditionally. A superseded `abort_scope` attempt (carries `gc.attempt` + `gc.logical_bead_id`) whose logical bead later passed therefore flipped the workflow root to fail at finalize even though later iterations recovered (observed on gascity#4008: a transient `control_dispatch_error` on `review-loop.iteration.3.review-codex` attempt 3, superseded by passing iterations 4-5, still failed the molecule at finalize). The guard now applies to the `hard` class too; a genuinely terminal, non-superseded hard `abort_scope` failure still fails the root. ## Interaction (verified) The review loop **is** a ralph loop, but the transient `control_dispatch_error` is stamped on a nested scope **member**, and `propagateScopeMemberMetadata` drops all `gc.*` keys while `setOutcomeAndClose` sets only `gc.outcome` — so `gc.failure_class` never reaches the iteration scope body the ralph check reads as its subject. FIX 1 therefore cannot hard-terminate the review loop on a transient `control_dispatch_error`; FIX 2 alone covers #4008. No quarantine reclassification needed. ## Tests `go test ./internal/dispatch/...` → 294 pass (incl. new `ralph_test.go` hard-terminate + soft-still-retries, and `runtime_test.go` superseded-hard-not-terminal + non-superseded-still-terminal). `go build ./cmd/gc/` + `go vet ./internal/dispatch/...` clean. Deployed to maintainer-city as `dev-d0a41db54` for live validation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- internal/dispatch/ralph.go | 28 +++++++ internal/dispatch/ralph_test.go | 109 +++++++++++++++++++++++++ internal/dispatch/runtime.go | 15 ++-- internal/dispatch/runtime_test.go | 131 ++++++++++++++++++++++++++++++ 4 files changed, 277 insertions(+), 6 deletions(-) diff --git a/internal/dispatch/ralph.go b/internal/dispatch/ralph.go index 0f1f78eb16..3130fdffe8 100644 --- a/internal/dispatch/ralph.go +++ b/internal/dispatch/ralph.go @@ -77,6 +77,34 @@ func processRalphCheck(store beads.Store, bead beads.Bead, opts ProcessOptions) return ControlResult{Processed: true, Action: "pass"}, nil } + // A hard-class subject failure is terminal: stop the loop immediately in a + // single attempt instead of cloning further attempts (the treadmill that + // abort_scope-killed molecules). This mirrors the retry dispatcher's explicit + // hard disposition (see processRetryEval in retry.go) but deliberately + // diverges on the empty class: classifyRetryAttempt maps an empty + // gc.failure_class to hard (retry.go: `case beadmeta.FailureClassHard, "":`), + // whereas this loop keeps an empty or transient class repairable and clones up + // to gc.max_attempts below. Only an explicit "hard" class terminates here. + if subject.Metadata[beadmeta.OutcomeMetadataKey] == beadmeta.OutcomeFail && + strings.TrimSpace(subject.Metadata[beadmeta.FailureClassMetadataKey]) == beadmeta.FailureClassHard { + if err := store.SetMetadataBatch(logicalID, map[string]string{ + beadmeta.OutcomeMetadataKey: beadmeta.OutcomeFail, + beadmeta.FailedAttemptMetadataKey: strconv.Itoa(attempt), + beadmeta.FailureClassMetadataKey: beadmeta.FailureClassHard, + beadmeta.FailureReasonMetadataKey: retryFailureReason(subject), + beadmeta.FinalDispositionMetadataKey: beadmeta.DispositionHardFail, + }); err != nil { + return ControlResult{}, fmt.Errorf("%s: marking logical hard failure: %w", logicalID, err) + } + if err := setOutcomeAndClose(store, bead.ID, beadmeta.OutcomeFail); err != nil { + return ControlResult{}, fmt.Errorf("%s: closing hard-failed check: %w", bead.ID, err) + } + if err := setOutcomeAndClose(store, logicalID, beadmeta.OutcomeFail); err != nil { + return ControlResult{}, fmt.Errorf("%s: closing hard-failed logical bead: %w", logicalID, err) + } + return ControlResult{Processed: true, Action: "hard-fail"}, nil + } + if attempt >= maxAttempts { if err := store.SetMetadataBatch(logicalID, map[string]string{ beadmeta.OutcomeMetadataKey: beadmeta.OutcomeFail, diff --git a/internal/dispatch/ralph_test.go b/internal/dispatch/ralph_test.go index 5fbe78b310..ea075302fe 100644 --- a/internal/dispatch/ralph_test.go +++ b/internal/dispatch/ralph_test.go @@ -301,3 +301,112 @@ func TestRunRalphCheckEnvTracksSubject(t *testing.T) { t.Errorf("artifact dir wrongly keyed by control bead %q; got %q", control.ID, result.Stdout) } } + +// TestProcessRalphCheckHardSubjectFailureTerminatesWithoutRetry proves FIX 1: +// when the ralph subject closed with gc.failure_class=hard, the loop stops in a +// single attempt (Action "hard-fail") instead of cloning attempts up to +// gc.max_attempts (the treadmill that abort_scope-killed molecules). +func TestProcessRalphCheckHardSubjectFailureTerminatesWithoutRetry(t *testing.T) { + t.Parallel() + + cityPath := t.TempDir() + // A passing check script proves termination is driven by the subject's + // hard-class failure alone; the check never gets a chance to pass. + checkPath := writeCheckScript(t, cityPath, "check.sh", "#!/bin/bash\nexit 0\n") + store, logical, run1, check1 := newSimpleRalphLoop(t, "implement", checkPath, 5) + + if err := store.SetMetadataBatch(run1.ID, map[string]string{ + "gc.outcome": "fail", + "gc.failure_class": "hard", + "gc.failure_reason": "external_live_head_changed", + }); err != nil { + t.Fatalf("stamp hard subject failure: %v", err) + } + if err := store.Close(run1.ID); err != nil { + t.Fatalf("close run1: %v", err) + } + + result, err := ProcessControl(store, check1, ProcessOptions{CityPath: cityPath}) + if err != nil { + t.Fatalf("ProcessControl(check1): %v", err) + } + if !result.Processed || result.Action != "hard-fail" { + t.Fatalf("result = %+v, want processed hard-fail", result) + } + + logicalAfter := mustGetBead(t, store, logical.ID) + if logicalAfter.Status != "closed" || logicalAfter.Metadata["gc.outcome"] != "fail" { + t.Fatalf("logical = status %q outcome %q, want closed/fail", logicalAfter.Status, logicalAfter.Metadata["gc.outcome"]) + } + if logicalAfter.Metadata["gc.failure_class"] != "hard" { + t.Fatalf("logical gc.failure_class = %q, want hard", logicalAfter.Metadata["gc.failure_class"]) + } + if logicalAfter.Metadata["gc.failure_reason"] != "external_live_head_changed" { + t.Fatalf("logical gc.failure_reason = %q, want external_live_head_changed", logicalAfter.Metadata["gc.failure_reason"]) + } + + checkAfter := mustGetBead(t, store, check1.ID) + if checkAfter.Status != "closed" || checkAfter.Metadata["gc.outcome"] != "fail" { + t.Fatalf("check = status %q outcome %q, want closed/fail", checkAfter.Status, checkAfter.Metadata["gc.outcome"]) + } + + rootID := run1.Metadata["gc.root_bead_id"] + all, err := listByWorkflowRoot(store, rootID) + if err != nil { + t.Fatalf("listByWorkflowRoot: %v", err) + } + for _, bead := range all { + if bead.Metadata["gc.attempt"] == "2" { + t.Fatalf("hard-fail must not clone another attempt; found %s (kind %q)", bead.ID, bead.Metadata["gc.kind"]) + } + } +} + +// TestProcessRalphCheckSoftSubjectFailureStillRetries is the FIX 1 regression +// guard: a non-hard (repairable) subject failure must still clone up to +// gc.max_attempts. Crucially an empty gc.failure_class stays repairable here, +// unlike retry-eval which maps empty to hard. +func TestProcessRalphCheckSoftSubjectFailureStillRetries(t *testing.T) { + t.Parallel() + + cityPath := t.TempDir() + checkPath := writeCheckScript(t, cityPath, "check.sh", "#!/bin/bash\nexit 1\n") + store, logical, run1, check1 := newSimpleRalphLoop(t, "implement", checkPath, 5) + + // gc.outcome=fail with no gc.failure_class is the ordinary repairable case. + if err := store.SetMetadata(run1.ID, "gc.outcome", "fail"); err != nil { + t.Fatalf("stamp soft subject failure: %v", err) + } + if err := store.Close(run1.ID); err != nil { + t.Fatalf("close run1: %v", err) + } + + result, err := ProcessControl(store, check1, ProcessOptions{CityPath: cityPath}) + if err != nil { + t.Fatalf("ProcessControl(check1): %v", err) + } + if !result.Processed || result.Action != "retry" { + t.Fatalf("result = %+v, want processed retry", result) + } + + logicalAfter := mustGetBead(t, store, logical.ID) + if logicalAfter.Status != "open" { + t.Fatalf("logical status = %q, want open (loop continues)", logicalAfter.Status) + } + + rootID := run1.Metadata["gc.root_bead_id"] + all, err := listByWorkflowRoot(store, rootID) + if err != nil { + t.Fatalf("listByWorkflowRoot: %v", err) + } + sawAttempt2 := false + for _, bead := range all { + if bead.Metadata["gc.attempt"] == "2" { + sawAttempt2 = true + break + } + } + if !sawAttempt2 { + t.Fatalf("soft failure must clone attempt 2; none found under root %s", rootID) + } +} diff --git a/internal/dispatch/runtime.go b/internal/dispatch/runtime.go index 81108d1144..02160af29b 100644 --- a/internal/dispatch/runtime.go +++ b/internal/dispatch/runtime.go @@ -1524,12 +1524,15 @@ func terminalAbortScopeFailure(bead beads.Bead) bool { if !beadOutcomeFailed(bead) { return false } - switch strings.TrimSpace(bead.Metadata[beadmeta.FailureClassMetadataKey]) { - case beadmeta.FailureClassTransient: + if strings.TrimSpace(bead.Metadata[beadmeta.FailureClassMetadataKey]) == beadmeta.FailureClassTransient { return false - case beadmeta.FailureClassHard: - return true - default: - return !isRetryAttemptSubject(bead) } + // The hard and absent/unknown classes are terminal only when the bead is + // NOT a superseded attempt. A superseded attempt carries gc.attempt + + // gc.logical_bead_id, meaning a later attempt/iteration of the same logical + // bead ran; its own failure must not outvote a passing later iteration at + // finalize (#4008). The logical bead's own final disposition is the + // authoritative signal. A genuinely terminal, non-superseded hard failure + // still returns true. + return !isRetryAttemptSubject(bead) } diff --git a/internal/dispatch/runtime_test.go b/internal/dispatch/runtime_test.go index 5edc6c8de1..6979396e32 100644 --- a/internal/dispatch/runtime_test.go +++ b/internal/dispatch/runtime_test.go @@ -2533,6 +2533,137 @@ func TestProcessWorkflowFinalizeIgnoresTransientRetryDescendant(t *testing.T) { } } +// TestTerminalAbortScopeFailureSupersededHardAttemptIsNotTerminal proves FIX 2: +// the supersession guard applies to the hard failure class too. A closed +// abort_scope bead that carries gc.attempt + gc.logical_bead_id is one attempt +// among many, so it must not count as a terminal abort even when it closed +// hard; a genuinely terminal, non-superseded hard failure still counts. +func TestTerminalAbortScopeFailureSupersededHardAttemptIsNotTerminal(t *testing.T) { + t.Parallel() + + base := map[string]string{ + "gc.on_fail": "abort_scope", + "gc.outcome": "fail", + "gc.failure_class": "hard", + } + clone := func(extra map[string]string) beads.Bead { + meta := map[string]string{} + for k, v := range base { + meta[k] = v + } + for k, v := range extra { + meta[k] = v + } + return beads.Bead{Status: "closed", Metadata: meta} + } + + // v1 pattern: a cloned retry-run attempt of a logical bead that later passed. + superseded := clone(map[string]string{ + "gc.kind": "retry-run", + "gc.attempt": "3", + "gc.logical_bead_id": "logical-1", + }) + if terminalAbortScopeFailure(superseded) { + t.Fatalf("superseded (v1) hard abort_scope attempt must not be terminal") + } + + // v2 pattern: original kind, distinguished by gc.attempt + gc.logical_bead_id. + supersededV2 := clone(map[string]string{ + "gc.attempt": "5", + "gc.logical_bead_id": "logical-2", + }) + if terminalAbortScopeFailure(supersededV2) { + t.Fatalf("superseded (v2) hard abort_scope attempt must not be terminal") + } + + // Non-superseded hard abort_scope failure is still terminal. + if !terminalAbortScopeFailure(clone(nil)) { + t.Fatalf("non-superseded hard abort_scope failure must be terminal") + } +} + +// TestProcessWorkflowFinalizeIgnoresSupersededHardRetryDescendant is the FIX 2 +// integration guard for #4008: a review loop whose iteration.3 attempt closed +// control_dispatch_error/hard but whose later iterations passed must finalize +// as pass, not fail. The superseded hard attempt must not outvote the passing +// later iterations at finalize. +func TestProcessWorkflowFinalizeIgnoresSupersededHardRetryDescendant(t *testing.T) { + t.Parallel() + + store := beads.NewMemStore() + workflow := mustCreateWorkflowBead(t, store, beads.Bead{ + Title: "workflow", + Type: "task", + Metadata: map[string]string{ + "gc.kind": "workflow", + "gc.formula_contract": "graph.v2", + }, + }) + // Logical review step that ultimately PASSED on a later iteration. + logical := mustCreateWorkflowBead(t, store, beads.Bead{ + Title: "review-codex logical", + Type: "task", + Status: "closed", + Metadata: map[string]string{ + "gc.kind": "retry", + "gc.root_bead_id": workflow.ID, + "gc.outcome": "pass", + }, + }) + // Superseded attempt.3 that closed control_dispatch_error/hard before the + // later iterations recovered. Carries gc.attempt + gc.logical_bead_id. + _ = mustCreateWorkflowBead(t, store, beads.Bead{ + Title: "review-codex attempt 3 (superseded)", + Type: "task", + Status: "closed", + Metadata: map[string]string{ + "gc.kind": "retry-run", + "gc.root_bead_id": workflow.ID, + "gc.scope_ref": "body", + "gc.scope_role": "member", + "gc.outcome": "fail", + "gc.failure_class": "hard", + "gc.failure_reason": "control_dispatch_error", + "gc.on_fail": "abort_scope", + "gc.attempt": "3", + "gc.logical_bead_id": logical.ID, + }, + }) + cleanup := mustCreateWorkflowBead(t, store, beads.Bead{ + Title: "cleanup", + Type: "task", + Status: "closed", + Metadata: map[string]string{ + "gc.root_bead_id": workflow.ID, + "gc.kind": "cleanup", + "gc.outcome": "pass", + }, + }) + finalizer := mustCreateWorkflowBead(t, store, beads.Bead{ + Title: "Finalize workflow", + Type: "task", + Metadata: map[string]string{ + "gc.kind": "workflow-finalize", + "gc.root_bead_id": workflow.ID, + }, + }) + + mustDepAdd(t, store, finalizer.ID, cleanup.ID, "blocks") + mustDepAdd(t, store, workflow.ID, finalizer.ID, "blocks") + + result, err := ProcessControl(store, finalizer, ProcessOptions{}) + if err != nil { + t.Fatalf("ProcessControl(workflow-finalize): %v", err) + } + if !result.Processed || result.Action != "workflow-pass" { + t.Fatalf("workflow result = %+v, want processed workflow-pass", result) + } + rootAfter := mustGetBead(t, store, workflow.ID) + if rootAfter.Status != "closed" || rootAfter.Metadata["gc.outcome"] != "pass" { + t.Fatalf("workflow = status %q outcome %q, want closed/pass", rootAfter.Status, rootAfter.Metadata["gc.outcome"]) + } +} + func TestProcessWorkflowFinalizeUsesCloseOperationForTerminalBeads(t *testing.T) { t.Parallel() From f9afd1acd749cbd2ef912bdaeaef32d10ff82233 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 8 Jul 2026 19:05:35 -0700 Subject: [PATCH 014/225] simplify(S14): one launchWorkflow chokepoint (single dedupe guard + compile-once) (#4037) Collapses all workflow-launch shapes onto a single launchWorkflow chokepoint with one duplicate-molecule dedupe guard and compile-once formula handling. Closes the #1053 duplicate-molecule window across all launch shapes, plus #720. Sibling of S13. Gates green; Fable-reviewed behavior-preserved. Spec at /data/projects/gascity/.claude/worktrees/simplification/engdocs/simplification/specs/S14-launch-chokepoint-spec.md. NOTE: spike/staged for review, not auto-merge. Co-authored-by: Claude Opus 4.8 (1M context) --- internal/sling/sling.go | 60 ++++-- internal/sling/sling_core.go | 5 +- .../sling/sling_launch_chokepoint_test.go | 199 ++++++++++++++++++ 3 files changed, 251 insertions(+), 13 deletions(-) create mode 100644 internal/sling/sling_launch_chokepoint_test.go diff --git a/internal/sling/sling.go b/internal/sling/sling.go index f4e01a9702..daf4aa3f72 100644 --- a/internal/sling/sling.go +++ b/internal/sling/sling.go @@ -1261,29 +1261,69 @@ func IsGraphWorkflowAttachment(store beads.Store, rootID string) bool { // graph routing if the formula is a graph.v2 workflow. func InstantiateSlingFormula(ctx context.Context, formulaName string, searchPaths []string, opts molecule.Options, sourceBeadID, scopeKind, scopeRef string, a config.Agent, deps SlingDeps, forceGraphV2Replace ...bool) (*molecule.Result, error) { SlingTracef("instantiate start formula=%s source=%s agent=%s parent=%s", formulaName, sourceBeadID, a.QualifiedName(), opts.ParentID) - if opts.PriorityOverride == nil && sourceBeadID != "" { - opts.PriorityOverride = BeadPriorityOverride(deps.Store, sourceBeadID) - } compileStart := time.Now() recipe, err := formula.CompileWithoutRuntimeVarValidation(ctx, formulaName, searchPaths, opts.Vars) if err != nil { SlingTracef("instantiate compile-error formula=%s dur=%s err=%v", formulaName, time.Since(compileStart), err) return nil, err } + SlingTracef("instantiate compiled formula=%s dur=%s steps=%d", formulaName, time.Since(compileStart), len(recipe.Steps)) + return InstantiateCompiledSlingFormula(ctx, recipe, formulaName, opts, sourceBeadID, scopeKind, scopeRef, a, deps, forceGraphV2Replace...) +} + +// InstantiateCompiledSlingFormula materializes an already-compiled formula +// recipe, applying graph routing when the recipe is a graph.v2 workflow. It is +// the single instantiation chokepoint for every sling launch shape: the caller +// compiles the recipe exactly once (compile-once, S14 I11/I12) and hands the +// same *formula.Recipe here, so the recipe that decides isGraph is the recipe +// that is validated and instantiated. +// +// The at-most-one-live-root-per-RootKey invariant (I1) is enforced by a +// cross-process sourceworkflow file lock on the RootKey — replacing the former +// process-local striped mutex that two processes (CLI + API) could each pass, +// which was the #1053 duplicate-molecule window. The RootKey lock nests inside +// any source-bead lock the caller already holds, preserving the fixed +// source→root acquisition order (I5); the keys never collide, so nesting is +// deadlock-free. +func InstantiateCompiledSlingFormula(ctx context.Context, recipe *formula.Recipe, formulaName string, opts molecule.Options, sourceBeadID, scopeKind, scopeRef string, a config.Agent, deps SlingDeps, forceGraphV2Replace ...bool) (*molecule.Result, error) { + if opts.PriorityOverride == nil && sourceBeadID != "" { + opts.PriorityOverride = BeadPriorityOverride(deps.Store, sourceBeadID) + } if err := molecule.ValidateRecipeRuntimeVars(recipe, opts); err != nil { SlingTracef("instantiate validate-error formula=%s err=%v", formulaName, err) return nil, err } graphWorkflow := graphroute.IsCompiledGraphWorkflow(recipe) + rootKey := "" if graphWorkflow { stampGraphV2RootMetadata(recipe, formulaName, opts.Vars, scopeKind, scopeRef) sourceBeadID = "" - if key := strings.TrimSpace(recipe.Steps[0].Metadata[beadmeta.Graphv2RootKeyMetadataKey]); key != "" { - unlock := lockGraphV2Root(key) - defer unlock() - } + rootKey = strings.TrimSpace(recipe.Steps[0].Metadata[beadmeta.Graphv2RootKeyMetadataKey]) } - SlingTracef("instantiate compiled formula=%s dur=%s steps=%d", formulaName, time.Since(compileStart), len(recipe.Steps)) + + materialize := func() (*molecule.Result, error) { + return materializeCompiledSlingFormula(ctx, recipe, formulaName, opts, sourceBeadID, scopeKind, scopeRef, graphWorkflow, a, deps, forceGraphV2Replace...) + } + if !graphWorkflow || rootKey == "" { + return materialize() + } + var result *molecule.Result + err := sourceworkflow.WithLock(ctx, deps.CityPath, sourceWorkflowLockScope(deps), rootKey, func() error { + var innerErr error + result, innerErr = materialize() + return innerErr + }) + if err != nil { + return nil, err + } + return result, nil +} + +// materializeCompiledSlingFormula performs the routing, dedupe lookup, and +// instantiation for a compiled recipe. For graph workflows the caller invokes +// it under the RootKey file lock so the live-root lookup and creation are +// atomic across processes. +func materializeCompiledSlingFormula(ctx context.Context, recipe *formula.Recipe, formulaName string, opts molecule.Options, sourceBeadID, scopeKind, scopeRef string, graphWorkflow bool, a config.Agent, deps SlingDeps, forceGraphV2Replace ...bool) (*molecule.Result, error) { graphStore := deps.graphStore() if err := graphroute.ApplyGraphRouting(recipe, &a, a.QualifiedName(), opts.Vars, sourceBeadID, scopeKind, scopeRef, deps.StoreRef, graphStore, deps.CityName, deps.Cfg, deps.graphrouteDeps()); err != nil { SlingTracef("instantiate decorate-error formula=%s err=%v", formulaName, err) @@ -1340,10 +1380,6 @@ func InstantiateSlingFormula(ctx context.Context, formulaName string, searchPath return result, nil } -func lockGraphV2Root(key string) func() { - return graphv2.LockKey(key) -} - func closeReplacedGraphV2Root(store beads.Store, rootID string) ([]sourceworkflow.WorkflowBeadSnapshot, error) { root, err := store.Get(rootID) if err != nil { diff --git a/internal/sling/sling_core.go b/internal/sling/sling_core.go index 9402b72a44..573cb1f9a6 100644 --- a/internal/sling/sling_core.go +++ b/internal/sling/sling_core.go @@ -268,7 +268,10 @@ func slingFormula(opts SlingOpts, deps SlingDeps) (SlingResult, error) { if a.SupportsMultipleSessions() && !formula.RecipeHasReadySurface(recipe) { return SlingResult{Target: a.QualifiedName(), FormulaName: opts.BeadOrFormula, Deprecations: inv.Deprecations}, fmt.Errorf("formula %q root is a molecule container, not Ready-visible work; scale-from-zero pools will not wake for this wisp. Convert the formula to phase=\"vapor\"/root-only or formulas v2 before routing it to a pool", opts.BeadOrFormula) } - mResult, err := InstantiateSlingFormula(context.Background(), opts.BeadOrFormula, searchPaths, molecule.Options{ + // Compile-once (S14): the recipe compiled above for the ready-surface check + // is the same one instantiated here — no redundant disk compile, and the + // isGraph/routing decision cannot drift from what is materialized. + mResult, err := InstantiateCompiledSlingFormula(context.Background(), recipe, opts.BeadOrFormula, molecule.Options{ Title: opts.Title, Vars: formulaVars, }, "", opts.ScopeKind, opts.ScopeRef, a, deps, opts.Force) diff --git a/internal/sling/sling_launch_chokepoint_test.go b/internal/sling/sling_launch_chokepoint_test.go new file mode 100644 index 0000000000..f852223871 --- /dev/null +++ b/internal/sling/sling_launch_chokepoint_test.go @@ -0,0 +1,199 @@ +package sling + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/citylayout" + "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/formula" + "github.com/gastownhall/gascity/internal/molecule" + "github.com/gastownhall/gascity/internal/runtime" + "github.com/gastownhall/gascity/internal/sourceworkflow" +) + +// liveGraphV2Roots returns the non-closed graph.v2 workflow roots in store. +func liveGraphV2Roots(t *testing.T, store beads.Store) []beads.Bead { + t.Helper() + roots, err := store.ListByMetadata(map[string]string{"gc.formula_contract": "graph.v2"}, 0, beads.WithBothTiers) + if err != nil { + t.Fatalf("ListByMetadata: %v", err) + } + var live []beads.Bead + for _, root := range roots { + if sourceworkflow.IsWorkflowRoot(root) && root.Status != "closed" { + live = append(live, root) + } + } + return live +} + +// TestLaunchWorkflowDuplicateAttemptReturnsSameLiveRoot proves the single +// dedupe guard: concurrent launches that resolve to the same RootKey converge +// on exactly one live root, and every loser receives the winner's root as an +// idempotent success (invariants I1 + I10) — never a second root, never an +// error. This is the #1053 "duplicate molecules" window closed. +func TestLaunchWorkflowDuplicateAttemptReturnsSameLiveRoot(t *testing.T) { + formulaDir := t.TempDir() + writeGraphV2ConvoyFormula(t, formulaDir) + cfg := graphV2SlingTestConfig(t, formulaDir) + deps := testDeps(cfg, runtime.NewFake(), newFakeRunner().run) + deps.CityPath = t.TempDir() + convoy, err := deps.Store.Create(beads.Bead{Title: "input", Type: "convoy"}) + if err != nil { + t.Fatal(err) + } + a := config.Agent{Name: "mayor", MaxActiveSessions: intPtr(1)} + opts := molecule.Options{Vars: map[string]string{"convoy_id": convoy.ID}} + + const n = 6 + var wg sync.WaitGroup + ids := make([]string, n) + errs := make([]error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + res, err := InstantiateSlingFormula(context.Background(), "graph-work", []string{formulaDir}, opts, "", "default", "", a, deps) + if err != nil { + errs[i] = err + return + } + ids[i] = res.RootID + }(i) + } + wg.Wait() + + first := ids[0] + for i, err := range errs { + if err != nil { + t.Fatalf("launch %d errored (a duplicate attempt must be an idempotent success, not an error): %v", i, err) + } + if ids[i] != first { + t.Fatalf("launch %d RootID = %q, want the shared winner root %q (I10)", i, ids[i], first) + } + } + if live := liveGraphV2Roots(t, deps.Store); len(live) != 1 { + t.Fatalf("live graph roots = %d, want exactly one (I1); roots=%+v", len(live), live) + } +} + +// TestLaunchWorkflowUsesCrossProcessFileLock proves the dedupe guard is the +// cross-process sourceworkflow file lock, not the old process-local striped +// mutex. A graph launch must leave a lock file under the city runtime dir; the +// process-local mutex never touched the filesystem, so this fails before the +// #1053 fix and passes after. +func TestLaunchWorkflowUsesCrossProcessFileLock(t *testing.T) { + formulaDir := t.TempDir() + writeGraphV2ConvoyFormula(t, formulaDir) + cfg := graphV2SlingTestConfig(t, formulaDir) + deps := testDeps(cfg, runtime.NewFake(), newFakeRunner().run) + deps.CityPath = t.TempDir() + convoy, err := deps.Store.Create(beads.Bead{Title: "input", Type: "convoy"}) + if err != nil { + t.Fatal(err) + } + a := config.Agent{Name: "mayor", MaxActiveSessions: intPtr(1)} + opts := molecule.Options{Vars: map[string]string{"convoy_id": convoy.ID}} + + if _, err := InstantiateSlingFormula(context.Background(), "graph-work", []string{formulaDir}, opts, "", "default", "", a, deps); err != nil { + t.Fatalf("InstantiateSlingFormula: %v", err) + } + + lockDir := filepath.Join(citylayout.RuntimeDataDir(deps.CityPath), "sling-source-locks") + entries, err := os.ReadDir(lockDir) + if err != nil { + t.Fatalf("reading sling-source-locks dir %s (a cross-process file lock must have been taken on the RootKey): %v", lockDir, err) + } + if len(entries) == 0 { + t.Fatalf("sling-source-locks dir %s is empty; the launch did not take a cross-process file lock on the RootKey", lockDir) + } +} + +// TestLaunchWorkflowLegitimateDistinctLaunchesAllowed proves the guard never +// blocks a legitimate launch (#720): distinct RootKeys (different convoy input) +// coexist, and a relaunch after the prior root is closed succeeds with a fresh +// root (invariants I6 + I7). +func TestLaunchWorkflowLegitimateDistinctLaunchesAllowed(t *testing.T) { + formulaDir := t.TempDir() + writeGraphV2ConvoyFormula(t, formulaDir) + cfg := graphV2SlingTestConfig(t, formulaDir) + deps := testDeps(cfg, runtime.NewFake(), newFakeRunner().run) + deps.CityPath = t.TempDir() + a := config.Agent{Name: "mayor", MaxActiveSessions: intPtr(1)} + + convoyA, err := deps.Store.Create(beads.Bead{Title: "input-a", Type: "convoy"}) + if err != nil { + t.Fatal(err) + } + convoyB, err := deps.Store.Create(beads.Bead{Title: "input-b", Type: "convoy"}) + if err != nil { + t.Fatal(err) + } + + optsA := molecule.Options{Vars: map[string]string{"convoy_id": convoyA.ID}} + optsB := molecule.Options{Vars: map[string]string{"convoy_id": convoyB.ID}} + rootA, err := InstantiateSlingFormula(context.Background(), "graph-work", []string{formulaDir}, optsA, "", "default", "", a, deps) + if err != nil { + t.Fatalf("launch A: %v", err) + } + rootB, err := InstantiateSlingFormula(context.Background(), "graph-work", []string{formulaDir}, optsB, "", "default", "", a, deps) + if err != nil { + t.Fatalf("launch B: %v", err) + } + if rootA.RootID == rootB.RootID { + t.Fatalf("distinct convoys shared a root %q, want two roots (I7)", rootA.RootID) + } + if live := liveGraphV2Roots(t, deps.Store); len(live) != 2 { + t.Fatalf("live graph roots = %d, want two distinct identities (I7)", len(live)) + } + + // Relaunch after the prior root is closed: never blocked (I6). + if _, err := sourceworkflow.CloseWorkflowSubtree(deps.Store, rootA.RootID); err != nil { + t.Fatalf("close root A: %v", err) + } + relaunch, err := InstantiateSlingFormula(context.Background(), "graph-work", []string{formulaDir}, optsA, "", "default", "", a, deps) + if err != nil { + t.Fatalf("relaunch after close: %v", err) + } + if relaunch.RootID == rootA.RootID { + t.Fatalf("relaunch reused closed root %q, want a fresh root (I6)", rootA.RootID) + } +} + +// TestInstantiateCompiledSlingFormulaAcceptsPrecompiledRecipe pins the +// compile-once primitive: a recipe compiled by the caller is instantiated +// without a second disk compile, materializing the same graph root. +func TestInstantiateCompiledSlingFormulaAcceptsPrecompiledRecipe(t *testing.T) { + formulaDir := t.TempDir() + writeGraphV2ConvoyFormula(t, formulaDir) + cfg := graphV2SlingTestConfig(t, formulaDir) + deps := testDeps(cfg, runtime.NewFake(), newFakeRunner().run) + deps.CityPath = t.TempDir() + convoy, err := deps.Store.Create(beads.Bead{Title: "input", Type: "convoy"}) + if err != nil { + t.Fatal(err) + } + a := config.Agent{Name: "mayor", MaxActiveSessions: intPtr(1)} + vars := map[string]string{"convoy_id": convoy.ID} + opts := molecule.Options{Vars: vars} + + recipe, err := formula.CompileWithoutRuntimeVarValidation(context.Background(), "graph-work", []string{formulaDir}, vars) + if err != nil { + t.Fatalf("compile: %v", err) + } + res, err := InstantiateCompiledSlingFormula(context.Background(), recipe, "graph-work", opts, "", "default", "", a, deps) + if err != nil { + t.Fatalf("InstantiateCompiledSlingFormula: %v", err) + } + if res.RootID == "" { + t.Fatalf("no root materialized") + } + if live := liveGraphV2Roots(t, deps.Store); len(live) != 1 { + t.Fatalf("live graph roots = %d, want one", len(live)) + } +} From e6a83bb2bccceb262794bc563618002c8592b90e Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 8 Jul 2026 19:16:21 -0700 Subject: [PATCH 015/225] simplify(S05): unify Agent patch/override merge + deep-clone (fixes Clone tombstone aliasing) (#4032) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What this does Lands **S05** — unifies the Agent patch/override merge and deep-clone paths in `internal/config`. Two merge bodies + two clone bodies collapse to one each (genuine −109 reduction), and it **fixes a latent tombstone-aliasing bug in `Clone`**. The manual field-sync convention is replaced with a reflect-based `TestAgentCloneIsDeep` (a stronger guard than the old copy-paste checklist). Touches the `config.Agent` field-sync zone (`AgentPatch` / merge / `Clone` / `poolAgents`), `cmd/gc/pool.go`, and the `AGENTS.md` note documenting the convention. ## Gates - `go build ./internal/config ./cmd/gc` — pass - `go vet ./internal/config ./cmd/gc` — pass - `go test ./internal/config` — pass - `go test ./cmd/gc` pool + agent-clone/field-sync/override/patch (`-run`) — pass ## Review verdict LAND via label PR (`status/needs-review-auto`) — field-sync-sensitive area plus a Clone-semantics fix warrant the auto-review pass. Nit deliberately not folded (left for auto-review): drop/comment the dead tombstone copies in `toAgentPatch`. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- AGENTS.md | 16 +- internal/config/config.go | 41 +++++ internal/config/field_sync_test.go | 73 +++++++++ internal/config/pack.go | 235 ++++++++--------------------- internal/config/patch.go | 17 ++- 5 files changed, 207 insertions(+), 175 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9d1d702883..1be74c8319 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -354,11 +354,17 @@ becoming more useful as models improve — it becomes LESS useful instead. city/test socket explicitly with `tmux -L ...`, or prefer `gc stop` for city shutdown. Treat personal tmux servers as out of bounds. - **Adding agent config fields:** When adding a field to `config.Agent`, - also add it to `AgentPatch`, `AgentOverride`, their apply functions - (`applyAgentPatch`, `applyAgentOverride`), and the `poolAgents` deep-copy - in `cmd/gc/pool.go`. `TestAgentFieldSync` enforces this for the struct - definitions; the apply functions and pool deep-copy must be checked - manually. + also add it to `AgentPatch` and `AgentOverride`, wire it into the shared + merge body `applyAgentMutation` (in `internal/config/patch.go`) — and, for + the rig-override path, copy it in `AgentOverride.toAgentPatch` — and, if the + field is a slice/map/pointer, deep-copy it in `Agent.Clone` + (`internal/config/config.go`). All four are test-guarded, so a missed field + fails the build: `TestAgentFieldSync` (struct field sets), + `TestApplyAgentPatchCoversAllFields` / `TestApplyAgentOverrideCoversAllFields` + (merge + `toAgentPatch` completeness), and `TestAgentCloneIsDeep` (clone + deepness). Both patch and rig override share `applyAgentMutation`, and both + the pack-load cache (`deepCopyAgents`) and pool expansion + (`cmd/gc/pool.go` `deepCopyAgent`) share `Agent.Clone`. - **Adding rig config fields:** When adding a field to `config.Rig`, also add the corresponding optional field to `RigPatch` and wire the merge into `applyRigPatch` so layered configs (fragments, patches) can diff --git a/internal/config/config.go b/internal/config/config.go index e1338a2f2e..d1c39c8d24 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3208,6 +3208,47 @@ type Agent struct { layout agentLayout } +// Clone returns a deep copy of the agent. Every slice, map, and pointer field +// is independently allocated so that mutating the clone never affects the +// original (and vice versa) — the guarantee the pack-load cache and pool +// expansion both rely on. Scalar and unexported value fields (including the +// source/layout provenance enums) are carried over by the initial struct copy. +// +// This is the single deep-copy source for Agent: deepCopyAgents (pack cache) +// and cmd/gc's pool deepCopyAgent both call through here. TestAgentCloneIsDeep +// enforces completeness — any new reference-type field must be cloned here or +// the build fails. +func (a Agent) Clone() Agent { + out := a + out.PreStart = append([]string(nil), a.PreStart...) + out.Args = append([]string(nil), a.Args...) + out.ProcessNames = append([]string(nil), a.ProcessNames...) + out.NamepoolNames = append([]string(nil), a.NamepoolNames...) + out.InstallAgentHooks = append([]string(nil), a.InstallAgentHooks...) + out.Skills = append([]string(nil), a.Skills...) + out.MCP = append([]string(nil), a.MCP...) + out.SessionSetup = append([]string(nil), a.SessionSetup...) + out.SessionLive = append([]string(nil), a.SessionLive...) + out.InjectFragments = append([]string(nil), a.InjectFragments...) + out.AppendFragments = append([]string(nil), a.AppendFragments...) + out.InheritedAppendFragments = append([]string(nil), a.InheritedAppendFragments...) + out.DependsOn = append([]string(nil), a.DependsOn...) + out.SharedSkills = append([]string(nil), a.SharedSkills...) + out.SharedMCP = append([]string(nil), a.SharedMCP...) + out.Env = deepCopyStringMap(a.Env) + out.OptionDefaults = deepCopyStringMap(a.OptionDefaults) + out.ReadyDelayMs = copyIntPtr(a.ReadyDelayMs) + out.MaxActiveSessions = copyIntPtr(a.MaxActiveSessions) + out.MinActiveSessions = copyIntPtr(a.MinActiveSessions) + out.EmitsPermissionWarning = copyBoolPtr(a.EmitsPermissionWarning) + out.HooksInstalled = copyBoolPtr(a.HooksInstalled) + out.InjectAssignedSkills = copyBoolPtr(a.InjectAssignedSkills) + out.Attach = copyBoolPtr(a.Attach) + out.DefaultSlingFormula = copyStringPtr(a.DefaultSlingFormula) + out.InheritedDefaultSlingFormula = copyStringPtr(a.InheritedDefaultSlingFormula) + return out +} + // agentSource enumerates the configuration origins recognized by // describeSource. Discovery sites stamp exactly one value per agent. type agentSource uint8 diff --git a/internal/config/field_sync_test.go b/internal/config/field_sync_test.go index 46e333a56b..eb8767b6e1 100644 --- a/internal/config/field_sync_test.go +++ b/internal/config/field_sync_test.go @@ -441,6 +441,24 @@ func TestApplyAgentOverrideCoversAllFields(t *testing.T) { if agent.MinActiveSessions == nil || *agent.MinActiveSessions != 2 || agent.MaxActiveSessions == nil || *agent.MaxActiveSessions != 10 { t.Errorf("Scaling not applied correctly: min=%v max=%v", agent.MinActiveSessions, agent.MaxActiveSessions) } + // Verify append modifiers extended the lists (not replaced). These guard + // the toAgentPatch adapter: a dropped *Append field would leave the base + // list at length 1. + if len(agent.PreStart) != 2 || agent.PreStart[1] != "pre-append" { + t.Errorf("PreStartAppend not applied: %v", agent.PreStart) + } + if len(agent.SessionSetup) != 2 || agent.SessionSetup[1] != "setup-append" { + t.Errorf("SessionSetupAppend not applied: %v", agent.SessionSetup) + } + if len(agent.SessionLive) != 2 || agent.SessionLive[1] != "live-append" { + t.Errorf("SessionLiveAppend not applied: %v", agent.SessionLive) + } + if len(agent.InstallAgentHooks) != 2 || agent.InstallAgentHooks[1] != "gemini" { + t.Errorf("InstallAgentHooksAppend not applied: %v", agent.InstallAgentHooks) + } + if len(agent.InjectFragments) != 2 || agent.InjectFragments[1] != "frag2" { + t.Errorf("InjectFragmentsAppend not applied: %v", agent.InjectFragments) + } } // TestProviderFieldSync verifies every ProviderSpec field (other than the @@ -517,6 +535,61 @@ func TestProviderFieldSync(t *testing.T) { } } +// TestAgentCloneIsDeep verifies that Agent.Clone independently allocates every +// slice, map, and pointer field, so a clone never shares backing storage with +// its source. It reflects over Agent, populates every settable reference-type +// field with real backing storage, clones, and asserts the clone's field +// points at distinct storage. A new reference-type field that Clone forgets to +// deep-copy fails here instead of silently aliasing (the in-process cousin of +// the pack-load-cache corruption class). +func TestAgentCloneIsDeep(t *testing.T) { + var orig Agent + v := reflect.ValueOf(&orig).Elem() + tp := v.Type() + + // Populate every settable reference-type field with non-empty backing + // storage. Unexported fields (source, layout) are value enums, not + // reference types, so skipping them is correct. + for i := 0; i < tp.NumField(); i++ { + f := v.Field(i) + if !f.CanSet() { + continue + } + switch f.Kind() { + case reflect.Slice: + f.Set(reflect.MakeSlice(f.Type(), 1, 1)) + case reflect.Map: + m := reflect.MakeMapWithSize(f.Type(), 1) + m.SetMapIndex(reflect.New(f.Type().Key()).Elem(), reflect.New(f.Type().Elem()).Elem()) + f.Set(m) + case reflect.Ptr: + f.Set(reflect.New(f.Type().Elem())) + } + } + + clone := orig.Clone() + cv := reflect.ValueOf(clone) + + for i := 0; i < tp.NumField(); i++ { + f := v.Field(i) + if !f.CanSet() { + continue + } + name := tp.Field(i).Name + cf := cv.Field(i) + switch f.Kind() { + case reflect.Slice, reflect.Map, reflect.Ptr: + if cf.IsNil() { + t.Errorf("Agent.Clone left reference field %q nil — add a deep copy in Clone()", name) + continue + } + if f.Pointer() == cf.Pointer() { + t.Errorf("Agent.Clone aliases field %q (shared backing storage) — add a deep copy in Clone()", name) + } + } + } +} + func structFields(t reflect.Type) []string { var names []string for i := 0; i < t.NumField(); i++ { diff --git a/internal/config/pack.go b/internal/config/pack.go index da93a370ca..145fefeedb 100644 --- a/internal/config/pack.go +++ b/internal/config/pack.go @@ -1727,29 +1727,7 @@ func clonePackLoadResult(in *packLoadResult) *packLoadResult { func deepCopyAgents(in []Agent) []Agent { out := make([]Agent, len(in)) for i := range in { - out[i] = in[i] - out[i].Args = append([]string(nil), in[i].Args...) - out[i].PreStart = append([]string(nil), in[i].PreStart...) - out[i].ProcessNames = append([]string(nil), in[i].ProcessNames...) - out[i].Env = deepCopyStringMap(in[i].Env) - out[i].OptionDefaults = deepCopyStringMap(in[i].OptionDefaults) - out[i].NamepoolNames = append([]string(nil), in[i].NamepoolNames...) - out[i].InstallAgentHooks = append([]string(nil), in[i].InstallAgentHooks...) - out[i].SessionSetup = append([]string(nil), in[i].SessionSetup...) - out[i].SessionLive = append([]string(nil), in[i].SessionLive...) - out[i].InjectFragments = append([]string(nil), in[i].InjectFragments...) - out[i].AppendFragments = append([]string(nil), in[i].AppendFragments...) - out[i].DependsOn = append([]string(nil), in[i].DependsOn...) - out[i].MaxActiveSessions = copyIntPtr(in[i].MaxActiveSessions) - out[i].MinActiveSessions = copyIntPtr(in[i].MinActiveSessions) - out[i].ReadyDelayMs = copyIntPtr(in[i].ReadyDelayMs) - out[i].EmitsPermissionWarning = copyBoolPtr(in[i].EmitsPermissionWarning) - out[i].HooksInstalled = copyBoolPtr(in[i].HooksInstalled) - out[i].InjectAssignedSkills = copyBoolPtr(in[i].InjectAssignedSkills) - out[i].DefaultSlingFormula = copyStringPtr(in[i].DefaultSlingFormula) - out[i].InheritedDefaultSlingFormula = copyStringPtr(in[i].InheritedDefaultSlingFormula) - out[i].InheritedAppendFragments = append([]string(nil), in[i].InheritedAppendFragments...) - out[i].Attach = copyBoolPtr(in[i].Attach) + out[i] = in[i].Clone() } return out } @@ -2734,156 +2712,75 @@ func applyOverrides(agents []Agent, overrides []AgentOverride, _ string) error { return nil } -// applyAgentOverride applies a single override to an agent. +// applyAgentOverride applies a single rig-scoped override to an agent. The +// override's Dir is the only field unique to the rig-override surface; every +// other overridable field is copied into an AgentPatch by toAgentPatch and +// merged through the shared applyAgentMutation body, so patch and override can +// never diverge field-by-field. See applyAgentMutation for the enforcement +// tests. func applyAgentOverride(a *Agent, ov *AgentOverride) { if ov.Dir != nil { a.Dir = *ov.Dir } - if ov.WorkDir != nil { - a.WorkDir = *ov.WorkDir - } - if ov.TmuxAlias != nil { - a.TmuxAlias = *ov.TmuxAlias - } - if ov.Scope != nil { - a.Scope = *ov.Scope - } - if ov.Suspended != nil { - a.Suspended = *ov.Suspended - } - if len(ov.PreStart) > 0 { - a.PreStart = append([]string(nil), ov.PreStart...) - } - if len(ov.PreStartAppend) > 0 { - a.PreStart = append(a.PreStart, ov.PreStartAppend...) - } - if ov.PromptTemplate != nil { - a.PromptTemplate = *ov.PromptTemplate - } - if ov.Session != nil { - a.Session = *ov.Session - } - if ov.Provider != nil { - a.Provider = *ov.Provider - } - if ov.Upstream != nil { - a.Upstream = *ov.Upstream - } - if ov.Args != nil { - a.Args = append([]string(nil), (*ov.Args)...) - } - if ov.StartCommand != nil { - a.StartCommand = *ov.StartCommand - } - if ov.Lifecycle != nil { - a.Lifecycle = *ov.Lifecycle - } - if ov.Nudge != nil { - a.Nudge = *ov.Nudge - } - if ov.IdleTimeout != nil { - a.IdleTimeout = *ov.IdleTimeout - } - if ov.MaxSessionAge != nil { - a.MaxSessionAge = *ov.MaxSessionAge - } - if ov.MaxSessionAgeJitter != nil { - a.MaxSessionAgeJitter = *ov.MaxSessionAgeJitter - } - if ov.SleepAfterIdle != nil { - a.SleepAfterIdle = NormalizeSleepAfterIdle(*ov.SleepAfterIdle) - a.SleepAfterIdleSource = "rig_override" - } - if len(ov.InstallAgentHooks) > 0 { - a.InstallAgentHooks = append([]string(nil), ov.InstallAgentHooks...) - } - if len(ov.InstallAgentHooksAppend) > 0 { - a.InstallAgentHooks = append(a.InstallAgentHooks, ov.InstallAgentHooksAppend...) - } - if ov.HooksInstalled != nil { - a.HooksInstalled = ov.HooksInstalled - } - if ov.InjectAssignedSkills != nil { - a.InjectAssignedSkills = ov.InjectAssignedSkills - } - if len(ov.SessionSetup) > 0 { - a.SessionSetup = append([]string(nil), ov.SessionSetup...) - } - if len(ov.SessionSetupAppend) > 0 { - a.SessionSetup = append(a.SessionSetup, ov.SessionSetupAppend...) - } - if ov.SessionSetupScript != nil { - a.SessionSetupScript = *ov.SessionSetupScript - } - if len(ov.SessionLive) > 0 { - a.SessionLive = append([]string(nil), ov.SessionLive...) - } - if len(ov.SessionLiveAppend) > 0 { - a.SessionLive = append(a.SessionLive, ov.SessionLiveAppend...) - } - if ov.OverlayDir != nil { - a.OverlayDir = *ov.OverlayDir - } - if ov.DefaultSlingFormula != nil { - a.DefaultSlingFormula = ov.DefaultSlingFormula - } - if ov.Attach != nil { - a.Attach = ov.Attach - } - if len(ov.DependsOn) > 0 { - a.DependsOn = append([]string(nil), ov.DependsOn...) - } - if ov.ResumeCommand != nil { - a.ResumeCommand = *ov.ResumeCommand - } - if ov.WakeMode != nil { - a.WakeMode = *ov.WakeMode - } - if ov.MouseMode != nil { - a.MouseMode = *ov.MouseMode - } - if ov.InjectFragments != nil { - a.InjectFragments = append([]string(nil), (*ov.InjectFragments)...) - } - if len(ov.AppendFragments) > 0 { - a.AppendFragments = append([]string(nil), ov.AppendFragments...) - } - if len(ov.InjectFragmentsAppend) > 0 { - a.InjectFragments = append(a.InjectFragments, ov.InjectFragmentsAppend...) - } - if ov.MaxActiveSessions != nil { - a.MaxActiveSessions = ov.MaxActiveSessions - } - if ov.MinActiveSessions != nil { - a.MinActiveSessions = ov.MinActiveSessions - } - if ov.ScaleCheck != nil { - a.ScaleCheck = *ov.ScaleCheck - } - // Env: additive merge. - if len(ov.Env) > 0 { - if a.Env == nil { - a.Env = make(map[string]string, len(ov.Env)) - } - for k, v := range ov.Env { - a.Env[k] = v - } - } - for _, k := range ov.EnvRemove { - delete(a.Env, k) - } - // OptionDefaults: additive merge (override keys win). - if len(ov.OptionDefaults) > 0 { - if a.OptionDefaults == nil { - a.OptionDefaults = make(map[string]string, len(ov.OptionDefaults)) - } - for k, v := range ov.OptionDefaults { - a.OptionDefaults[k] = v - } - } - // Pool: sub-field patching. - if ov.Pool != nil { - applyPoolOverride(a, ov.Pool) + applyAgentMutation(a, ov.toAgentPatch(), SessionSleepSourceRigOverride) +} + +// toAgentPatch adapts a rig-scoped AgentOverride into the equivalent +// AgentPatch so both override surfaces share applyAgentMutation. Only the +// overridable fields are copied; the targeting keys (Agent, Dir) are handled +// by the caller. TestAgentFieldSync keeps the two field sets aligned, and +// TestApplyAgentOverrideCoversAllFields proves every field copied here reaches +// the agent — a missed field fails the build. +func (ov *AgentOverride) toAgentPatch() *AgentPatch { + return &AgentPatch{ + WorkDir: ov.WorkDir, + TmuxAlias: ov.TmuxAlias, + Scope: ov.Scope, + Suspended: ov.Suspended, + Pool: ov.Pool, + Env: ov.Env, + EnvRemove: ov.EnvRemove, + PreStart: ov.PreStart, + PromptTemplate: ov.PromptTemplate, + Session: ov.Session, + Provider: ov.Provider, + Upstream: ov.Upstream, + Args: ov.Args, + StartCommand: ov.StartCommand, + Lifecycle: ov.Lifecycle, + Nudge: ov.Nudge, + IdleTimeout: ov.IdleTimeout, + MaxSessionAge: ov.MaxSessionAge, + MaxSessionAgeJitter: ov.MaxSessionAgeJitter, + SleepAfterIdle: ov.SleepAfterIdle, + InstallAgentHooks: ov.InstallAgentHooks, + Skills: ov.Skills, + MCP: ov.MCP, + SkillsAppend: ov.SkillsAppend, + MCPAppend: ov.MCPAppend, + HooksInstalled: ov.HooksInstalled, + InjectAssignedSkills: ov.InjectAssignedSkills, + SessionSetup: ov.SessionSetup, + SessionSetupScript: ov.SessionSetupScript, + SessionLive: ov.SessionLive, + OverlayDir: ov.OverlayDir, + DefaultSlingFormula: ov.DefaultSlingFormula, + InjectFragments: ov.InjectFragments, + AppendFragments: ov.AppendFragments, + Attach: ov.Attach, + DependsOn: ov.DependsOn, + ResumeCommand: ov.ResumeCommand, + WakeMode: ov.WakeMode, + MouseMode: ov.MouseMode, + PreStartAppend: ov.PreStartAppend, + SessionSetupAppend: ov.SessionSetupAppend, + SessionLiveAppend: ov.SessionLiveAppend, + InstallAgentHooksAppend: ov.InstallAgentHooksAppend, + InjectFragmentsAppend: ov.InjectFragmentsAppend, + MaxActiveSessions: ov.MaxActiveSessions, + MinActiveSessions: ov.MinActiveSessions, + ScaleCheck: ov.ScaleCheck, + OptionDefaults: ov.OptionDefaults, } } diff --git a/internal/config/patch.go b/internal/config/patch.go index 3adfbd05a9..3858144653 100644 --- a/internal/config/patch.go +++ b/internal/config/patch.go @@ -428,6 +428,21 @@ func applyAgentPatch(cfg *City, patch *AgentPatch) error { } func applyAgentPatchFields(a *Agent, p *AgentPatch) { + applyAgentMutation(a, p, SessionSleepSourceAgentPatch) +} + +// applyAgentMutation applies the overridable fields of an AgentPatch to an +// agent. Agent patches and rig-scoped agent overrides share this single merge +// body: applyAgentOverride adapts an AgentOverride into an AgentPatch (via +// toAgentPatch) and delegates here, so the two override paths can never +// silently diverge field-by-field. sleepSource records which config layer +// supplied SleepAfterIdle (SessionSleepSourceAgentPatch for patches, +// SessionSleepSourceRigOverride for rig overrides). +// +// TestApplyAgentPatchCoversAllFields and TestApplyAgentOverrideCoversAllFields +// enforce that every overridable field is wired in here (and, for the override +// path, copied by toAgentPatch); a missed field fails the build. +func applyAgentMutation(a *Agent, p *AgentPatch, sleepSource string) { if p.WorkDir != nil { a.WorkDir = *p.WorkDir } @@ -481,7 +496,7 @@ func applyAgentPatchFields(a *Agent, p *AgentPatch) { } if p.SleepAfterIdle != nil { a.SleepAfterIdle = NormalizeSleepAfterIdle(*p.SleepAfterIdle) - a.SleepAfterIdleSource = "agent_patch" + a.SleepAfterIdleSource = sleepSource } if len(p.InstallAgentHooks) > 0 { a.InstallAgentHooks = append([]string(nil), p.InstallAgentHooks...) From e8162d2fd420eb99979ad882c980407ad4057033 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 8 Jul 2026 19:38:35 -0700 Subject: [PATCH 016/225] fix(webhook): post-merge hardening for supervisor webhook receiver (#3984 follow-up) (#3987) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Post-merge hardening for the supervisor webhook receiver Follow-up to the merged **#3984** (`feat(webhook): generic supervisor webhook receiver`). A post-merge review of the landed range `e1ba0a13..0f0bb104` found six major issues plus several minors; this PR applies the R1–R4 hardening the design proposal and security red-team required. ### Major fixes - **Rig binding (R4).** Added `Webhook.Rig`. A rig-scoped webhook now dispatches to its own rig and refuses a rule that targets a foreign rig — previously a `scope="rig"` webhook could never dispatch (no rig binding). - **`bearer_env` / `allowed_cidrs` enforced (R1).** Both documented controls were silent no-ops. Bearer tokens are now compared constant-time; `allowed_cidrs` is matched against the direct connection address (X-Forwarded-For is deliberately not trusted, mirroring the supervisor's `remote_addr_class` policy). Both are validated in the `GC_WEBHOOK_*` operator namespace at config load. - **Public webhooks cannot fire exec orders (R4).** Public deliveries are limited to formula orders, removing the in-process `sh -c` RCE sink the red-team flagged. - **Slack/Discord event rules match (correctness).** The event type is now derived from the verified body, so payload-carried rules (e.g. `event = "message"`, non-PING Discord interactions) match instead of being verified no-ops. - **Perimeter/rate-limit ordering (R2/DoS).** The visibility perimeter and rate limiter run before the method check, and the cheap unauthenticated rejects (perimeter, method, rate-limit) are non-evented — removing the event-flood amplifier. A non-POST probe of a private/tenant hook now returns 404 (was 405, which leaked existence). - **`allow_public` content-scoped consent (R3).** A grant is honored only when its digest matches the webhook's current security-relevant content, so a content-swap upgrade auto-downgrades the hook to tenant until the operator re-consents to the new digest (the warning names the digest). ### Minor fixes - Overflow-safe replay-window check (a far-future signed timestamp no longer clamp-underflows past the window in Slack/Discord). - Required order params treat an empty extracted value as missing. - Config validation mirrors the verifier's secret-env namespace/presence rules (and requires `secret_env` for `discord-ed25519`). - Per-hook fair dedup eviction so a high-volume hook cannot evict a quiet hook's replay entries under the shared per-city cap. - `recover()` boundary around detached dispatch goroutines (a panic while processing webhook-derived args no longer crashes the supervisor). ### Notes - `handleHookProxy` (cyclomatic 21→5) and `ValidateWebhooks` (cognitive 28→~2) were decomposed into focused helpers. - Config schema/docs regenerated for the new `rig` field; OpenAPI unchanged. ### Tests New unit + end-to-end coverage for every fix (own-rig dispatch vs foreign-rig reject, bearer/CIDR rejection, public→exec refusal, Slack `event.type` matching, non-evented perimeter/method rejects, content-digest consent, far-future timestamp rejection, per-hook dedup fairness, dispatch-goroutine panic recovery). Fast local suite + `go vet` + `golangci-lint` green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 --- cmd/gc/order_dispatch.go | 20 +- cmd/gc/order_dispatch_test.go | 26 ++ docs/reference/config.md | 5 +- docs/reference/schema/city-schema.json | 8 +- docs/reference/schema/city-schema.txt | 8 +- docs/reference/schema/pack-schema.json | 6 +- docs/reference/schema/pack-schema.txt | 6 +- internal/api/handler_webhook.go | 334 +++++++++++++------- internal/api/handler_webhook_test.go | 347 ++++++++++++++++++++- internal/api/server.go | 14 + internal/api/webhook_access.go | 119 ++++++++ internal/api/webhook_dedup.go | 80 ++++- internal/api/webhook_dedup_test.go | 113 +++++++ internal/api/webhook_events.go | 39 ++- internal/config/webhook.go | 404 ++++++++++++++++++++----- internal/config/webhook_test.go | 291 ++++++++++++++++-- internal/orders/order.go | 9 +- internal/orders/order_test.go | 18 +- internal/webhooksink/sink.go | 34 ++- internal/webhooksink/sink_test.go | 42 +++ internal/webhookverify/discord.go | 44 ++- internal/webhookverify/discord_test.go | 52 ++++ internal/webhookverify/secret.go | 6 +- internal/webhookverify/slack.go | 34 ++- internal/webhookverify/slack_test.go | 55 ++++ internal/webhookverify/verify.go | 27 +- 26 files changed, 1861 insertions(+), 280 deletions(-) create mode 100644 internal/api/webhook_access.go diff --git a/cmd/gc/order_dispatch.go b/cmd/gc/order_dispatch.go index 9bdeb055cd..ed2e787e4a 100644 --- a/cmd/gc/order_dispatch.go +++ b/cmd/gc/order_dispatch.go @@ -676,7 +676,7 @@ func (m *memoryOrderDispatcher) launchDispatchOne(ctx context.Context, store bea if m.dispatchCtx == nil { go func() { defer onDone() - m.dispatchOne(ctx, store, target, a, cityPath, trackingID, vars, execEnv) + m.runDispatchGuarded(ctx, store, target, a, cityPath, trackingID, vars, execEnv) }() return } @@ -689,10 +689,26 @@ func (m *memoryOrderDispatcher) launchDispatchOne(ctx context.Context, store bea defer onDone() defer stopAfter() defer cancelMerged() - m.dispatchOne(mergedCtx, store, target, a, cityPath, trackingID, vars, execEnv) + m.runDispatchGuarded(mergedCtx, store, target, a, cityPath, trackingID, vars, execEnv) }() } +// runDispatchGuarded runs dispatchOne with a panic boundary. A dispatch goroutine +// is detached from the request/tick that launched it — the webhook fast-ACK path +// in particular returns its HTTP response (past any recovery middleware) before +// this goroutine runs — so a panic here (e.g. while processing untrusted +// webhook-derived args) would otherwise crash the whole supervisor. dispatchOne's +// own defers close the tracking bead as the stack unwinds before recovery here; +// this boundary logs the panic and contains it to the single dispatch. +func (m *memoryOrderDispatcher) runDispatchGuarded(ctx context.Context, store beads.Store, target execStoreTarget, a orders.Order, cityPath, trackingID string, vars, execEnv map[string]string) { + defer func() { + if p := recover(); p != nil { + logDispatchError(m.stderr, "gc: order %s: dispatch goroutine panic (tracking %s): %v", a.ScopedName(), trackingID, p) + } + }() + m.dispatchOne(ctx, store, target, a, cityPath, trackingID, vars, execEnv) +} + // launchResolvedDispatch is the single fire path shared by the controller tick // loop and the webhook dispatch seam (memoryOrderDispatcher.Dispatch). It writes // the order-tracking bead that suppresses re-fire, registers the in-flight diff --git a/cmd/gc/order_dispatch_test.go b/cmd/gc/order_dispatch_test.go index db564a502c..b7f2fee678 100644 --- a/cmd/gc/order_dispatch_test.go +++ b/cmd/gc/order_dispatch_test.go @@ -9551,3 +9551,29 @@ func TestCarryLastRunCacheFrom(t *testing.T) { t.Errorf("cache size = %d after no-op carries, want 2", len(next.lastRunCache)) } } + +// A panic inside a detached dispatch goroutine must be contained by +// runDispatchGuarded, not crash the supervisor (the webhook fast-ACK path has +// already returned its HTTP response past any recovery middleware). A nil +// recorder makes dispatchOne panic at its OrderFired emit; the guard must +// recover and log it rather than let the panic escape the goroutine. +func TestRunDispatchGuardedRecoversPanic(t *testing.T) { + var logs bytes.Buffer + m := &memoryOrderDispatcher{stderr: &logs} // rec is nil → dispatchOne panics on Record + + order := orders.Order{Name: "boom", Trigger: "webhook", Formula: "f"} + done := make(chan struct{}) + go func() { + defer close(done) + m.runDispatchGuarded(context.Background(), beads.NewMemStore(), execStoreTarget{}, order, "/city", "track-x", nil, nil) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("runDispatchGuarded did not return — a dispatch-goroutine panic was not recovered") + } + if !strings.Contains(logs.String(), "panic") { + t.Errorf("expected the recovered panic to be logged, got %q", logs.String()) + } +} diff --git a/docs/reference/config.md b/docs/reference/config.md index 6b0e112900..1a0a6843e9 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -862,6 +862,7 @@ Webhook declares a city- or rig-scoped inbound HTTP receiver mounted under /v0/c |-------|------|----------|---------|-------------| | `name` | string | **yes** | | Name is the unique webhook identifier and mount segment. | | `scope` | string | | | Scope selects city- or rig-scoped dispatch semantics, mirroring Order.Scope. Empty defaults to city. Enum: `city`, `rig` | +| `rig` | string | | | Rig is the authoritative rig binding for a rig-scoped webhook (Scope=="rig"). It is REQUIRED when scope="rig" and forbidden otherwise: the receiver copies it into the dispatch scope so the sink constrains delivery to this rig (R4), and a rule that names any other rig is refused. Without it a rig-scoped webhook fails closed (it can target no rig). Leave unset for city scope. | | `publication` | ServicePublicationConfig | | | Publication declares generic publication intent, reusing the service publication contract. Pack/fragment-contributed public webhooks are capped to tenant unless the city grants them via [webhooks].allow_public. | | `verify` | WebhookVerify | | | Verify declares the signature verification scheme and its inputs. | | `rule` | []WebhookRule | | | Rules maps verified provider events to dispatch targets. | @@ -875,7 +876,7 @@ WebhookAllowPublic is one operator-authored public-exposure grant. |-------|------|----------|---------|-------------| | `name` | string | **yes** | | Name is the webhook name being granted public exposure. | | `source` | string | **yes** | | Source is the pack/fragment provenance the grant is scoped to. Matched against the webhook's stamped SourceDir. | -| `digest` | string | | | Digest optionally pins the content digest of the granted webhook's security-relevant fields. TODO(R3): compute and enforce this digest over {visibility, verify scheme/secret_env/secret_key/trust-root, each rule's event/match/order/rig/target} so a content-swap upgrade auto-downgrades to tenant until the operator re-consents. E2 matches on {name, source} only; the digest field is reserved for that follow-up. | +| `digest` | string | | | Digest pins the content digest of the granted webhook's security-relevant fields (see WebhookContentDigest). It is REQUIRED for the grant to honor public exposure: applyWebhookPackGuard recomputes the digest at load and caps the webhook to tenant when the grant has no digest or the digest no longer matches (R3 content-scoped consent), so a content-swap upgrade of a public hook auto-downgrades until the operator re-consents to the new digest. The downgrade warning names the digest to pin. | ## WebhookJWTPolicy @@ -942,7 +943,7 @@ WebhookVerify declares how an inbound delivery is authenticated. | `secret_key` | string | | | SecretKey is an optional stable rotation-slot identifier. Empty defaults to SecretEnv. | | `signature_header` | string | | | SignatureHeader overrides the request header carrying the signature for generic HMAC schemes (e.g. X-Plane-Signature). | | `event_header` | string | | | EventHeader names the request header carrying the provider event type. | -| `dedup_header` | string | | | DedupHeader names the request header carrying the delivery id used for at-least-once dedup. | +| `dedup_header` | string | | | DedupHeader names the request header whose value is surfaced as the delivery id on webhook.received events for observability. It does NOT key at-least-once dedup for the signature-only schemes (github-hmac-sha256, hmac-sha256, slack-v0, discord-ed25519): those dedup on a hash of the signed body, because an unsigned or coarse header cannot safely key dedup — a captured valid delivery could be replayed under a fresh header id to re-fire the order. Only jwt-jwks keys dedup directly, on its signed per-delivery-unique "jti". As a consequence two deliveries with byte-identical signed bodies inside the dedup window collapse to one dispatch, so a source that must resend an identical payload has to carry a unique value inside the signed body. | | `timestamp_header` | string | | | TimestampHeader optionally names a request header carrying a signed timestamp for replay defense. | | `replay_window` | string | | | ReplayWindow bounds the accepted signed-timestamp skew (Go duration). | | `issuer` | string | | | Issuer, JWKSURL, and Audience pin the jwt-jwks trust anchor. Per the security review (R1) these are operator-owned and must be declared in city.toml, never in pack TOML. | diff --git a/docs/reference/schema/city-schema.json b/docs/reference/schema/city-schema.json index 4a415d8de5..f536435f1b 100644 --- a/docs/reference/schema/city-schema.json +++ b/docs/reference/schema/city-schema.json @@ -2881,6 +2881,10 @@ ], "description": "Scope selects city- or rig-scoped dispatch semantics, mirroring\nOrder.Scope. Empty defaults to city." }, + "rig": { + "type": "string", + "description": "Rig is the authoritative rig binding for a rig-scoped webhook (Scope==\"rig\").\nIt is REQUIRED when scope=\"rig\" and forbidden otherwise: the receiver copies\nit into the dispatch scope so the sink constrains delivery to this rig (R4),\nand a rule that names any other rig is refused. Without it a rig-scoped\nwebhook fails closed (it can target no rig). Leave unset for city scope." + }, "publication": { "$ref": "#/$defs/ServicePublicationConfig", "description": "Publication declares generic publication intent, reusing the service\npublication contract. Pack/fragment-contributed public webhooks are\ncapped to tenant unless the city grants them via [webhooks].allow_public." @@ -2920,7 +2924,7 @@ }, "digest": { "type": "string", - "description": "Digest optionally pins the content digest of the granted webhook's\nsecurity-relevant fields.\n\nTODO(R3): compute and enforce this digest over\n{visibility, verify scheme/secret_env/secret_key/trust-root, each rule's\nevent/match/order/rig/target} so a content-swap upgrade auto-downgrades\nto tenant until the operator re-consents. E2 matches on {name, source}\nonly; the digest field is reserved for that follow-up." + "description": "Digest pins the content digest of the granted webhook's security-relevant\nfields (see WebhookContentDigest). It is REQUIRED for the grant to honor\npublic exposure: applyWebhookPackGuard recomputes the digest at load and\ncaps the webhook to tenant when the grant has no digest or the digest no\nlonger matches (R3 content-scoped consent), so a content-swap upgrade of a\npublic hook auto-downgrades until the operator re-consents to the new\ndigest. The downgrade warning names the digest to pin." } }, "additionalProperties": false, @@ -3094,7 +3098,7 @@ }, "dedup_header": { "type": "string", - "description": "DedupHeader names the request header carrying the delivery id used for\nat-least-once dedup." + "description": "DedupHeader names the request header whose value is surfaced as the\ndelivery id on webhook.received events for observability. It does NOT key\nat-least-once dedup for the signature-only schemes (github-hmac-sha256,\nhmac-sha256, slack-v0, discord-ed25519): those dedup on a hash of the\nsigned body, because an unsigned or coarse header cannot safely key dedup —\na captured valid delivery could be replayed under a fresh header id to\nre-fire the order. Only jwt-jwks keys dedup directly, on its signed\nper-delivery-unique \"jti\". As a consequence two deliveries with\nbyte-identical signed bodies inside the dedup window collapse to one\ndispatch, so a source that must resend an identical payload has to carry a\nunique value inside the signed body." }, "timestamp_header": { "type": "string", diff --git a/docs/reference/schema/city-schema.txt b/docs/reference/schema/city-schema.txt index 4a415d8de5..f536435f1b 100644 --- a/docs/reference/schema/city-schema.txt +++ b/docs/reference/schema/city-schema.txt @@ -2881,6 +2881,10 @@ ], "description": "Scope selects city- or rig-scoped dispatch semantics, mirroring\nOrder.Scope. Empty defaults to city." }, + "rig": { + "type": "string", + "description": "Rig is the authoritative rig binding for a rig-scoped webhook (Scope==\"rig\").\nIt is REQUIRED when scope=\"rig\" and forbidden otherwise: the receiver copies\nit into the dispatch scope so the sink constrains delivery to this rig (R4),\nand a rule that names any other rig is refused. Without it a rig-scoped\nwebhook fails closed (it can target no rig). Leave unset for city scope." + }, "publication": { "$ref": "#/$defs/ServicePublicationConfig", "description": "Publication declares generic publication intent, reusing the service\npublication contract. Pack/fragment-contributed public webhooks are\ncapped to tenant unless the city grants them via [webhooks].allow_public." @@ -2920,7 +2924,7 @@ }, "digest": { "type": "string", - "description": "Digest optionally pins the content digest of the granted webhook's\nsecurity-relevant fields.\n\nTODO(R3): compute and enforce this digest over\n{visibility, verify scheme/secret_env/secret_key/trust-root, each rule's\nevent/match/order/rig/target} so a content-swap upgrade auto-downgrades\nto tenant until the operator re-consents. E2 matches on {name, source}\nonly; the digest field is reserved for that follow-up." + "description": "Digest pins the content digest of the granted webhook's security-relevant\nfields (see WebhookContentDigest). It is REQUIRED for the grant to honor\npublic exposure: applyWebhookPackGuard recomputes the digest at load and\ncaps the webhook to tenant when the grant has no digest or the digest no\nlonger matches (R3 content-scoped consent), so a content-swap upgrade of a\npublic hook auto-downgrades until the operator re-consents to the new\ndigest. The downgrade warning names the digest to pin." } }, "additionalProperties": false, @@ -3094,7 +3098,7 @@ }, "dedup_header": { "type": "string", - "description": "DedupHeader names the request header carrying the delivery id used for\nat-least-once dedup." + "description": "DedupHeader names the request header whose value is surfaced as the\ndelivery id on webhook.received events for observability. It does NOT key\nat-least-once dedup for the signature-only schemes (github-hmac-sha256,\nhmac-sha256, slack-v0, discord-ed25519): those dedup on a hash of the\nsigned body, because an unsigned or coarse header cannot safely key dedup —\na captured valid delivery could be replayed under a fresh header id to\nre-fire the order. Only jwt-jwks keys dedup directly, on its signed\nper-delivery-unique \"jti\". As a consequence two deliveries with\nbyte-identical signed bodies inside the dedup window collapse to one\ndispatch, so a source that must resend an identical payload has to carry a\nunique value inside the signed body." }, "timestamp_header": { "type": "string", diff --git a/docs/reference/schema/pack-schema.json b/docs/reference/schema/pack-schema.json index 187ea85a36..cf559bd280 100644 --- a/docs/reference/schema/pack-schema.json +++ b/docs/reference/schema/pack-schema.json @@ -1468,6 +1468,10 @@ ], "description": "Scope selects city- or rig-scoped dispatch semantics, mirroring\nOrder.Scope. Empty defaults to city." }, + "rig": { + "type": "string", + "description": "Rig is the authoritative rig binding for a rig-scoped webhook (Scope==\"rig\").\nIt is REQUIRED when scope=\"rig\" and forbidden otherwise: the receiver copies\nit into the dispatch scope so the sink constrains delivery to this rig (R4),\nand a rule that names any other rig is refused. Without it a rig-scoped\nwebhook fails closed (it can target no rig). Leave unset for city scope." + }, "publication": { "$ref": "#/$defs/ServicePublicationConfig", "description": "Publication declares generic publication intent, reusing the service\npublication contract. Pack/fragment-contributed public webhooks are\ncapped to tenant unless the city grants them via [webhooks].allow_public." @@ -1563,7 +1567,7 @@ }, "dedup_header": { "type": "string", - "description": "DedupHeader names the request header carrying the delivery id used for\nat-least-once dedup." + "description": "DedupHeader names the request header whose value is surfaced as the\ndelivery id on webhook.received events for observability. It does NOT key\nat-least-once dedup for the signature-only schemes (github-hmac-sha256,\nhmac-sha256, slack-v0, discord-ed25519): those dedup on a hash of the\nsigned body, because an unsigned or coarse header cannot safely key dedup —\na captured valid delivery could be replayed under a fresh header id to\nre-fire the order. Only jwt-jwks keys dedup directly, on its signed\nper-delivery-unique \"jti\". As a consequence two deliveries with\nbyte-identical signed bodies inside the dedup window collapse to one\ndispatch, so a source that must resend an identical payload has to carry a\nunique value inside the signed body." }, "timestamp_header": { "type": "string", diff --git a/docs/reference/schema/pack-schema.txt b/docs/reference/schema/pack-schema.txt index 187ea85a36..cf559bd280 100644 --- a/docs/reference/schema/pack-schema.txt +++ b/docs/reference/schema/pack-schema.txt @@ -1468,6 +1468,10 @@ ], "description": "Scope selects city- or rig-scoped dispatch semantics, mirroring\nOrder.Scope. Empty defaults to city." }, + "rig": { + "type": "string", + "description": "Rig is the authoritative rig binding for a rig-scoped webhook (Scope==\"rig\").\nIt is REQUIRED when scope=\"rig\" and forbidden otherwise: the receiver copies\nit into the dispatch scope so the sink constrains delivery to this rig (R4),\nand a rule that names any other rig is refused. Without it a rig-scoped\nwebhook fails closed (it can target no rig). Leave unset for city scope." + }, "publication": { "$ref": "#/$defs/ServicePublicationConfig", "description": "Publication declares generic publication intent, reusing the service\npublication contract. Pack/fragment-contributed public webhooks are\ncapped to tenant unless the city grants them via [webhooks].allow_public." @@ -1563,7 +1567,7 @@ }, "dedup_header": { "type": "string", - "description": "DedupHeader names the request header carrying the delivery id used for\nat-least-once dedup." + "description": "DedupHeader names the request header whose value is surfaced as the\ndelivery id on webhook.received events for observability. It does NOT key\nat-least-once dedup for the signature-only schemes (github-hmac-sha256,\nhmac-sha256, slack-v0, discord-ed25519): those dedup on a hash of the\nsigned body, because an unsigned or coarse header cannot safely key dedup —\na captured valid delivery could be replayed under a fresh header id to\nre-fire the order. Only jwt-jwks keys dedup directly, on its signed\nper-delivery-unique \"jti\". As a consequence two deliveries with\nbyte-identical signed bodies inside the dedup window collapse to one\ndispatch, so a source that must resend an identical payload has to carry a\nunique value inside the signed body." }, "timestamp_header": { "type": "string", diff --git a/internal/api/handler_webhook.go b/internal/api/handler_webhook.go index 50633cc748..49091282fa 100644 --- a/internal/api/handler_webhook.go +++ b/internal/api/handler_webhook.go @@ -27,6 +27,15 @@ import ( // payloads while staying well under GitHub's own 25 MiB delivery ceiling. const defaultMaxWebhookBodyBytes int64 = 5 << 20 +// webhookRequest carries the resolved receiver context for one /hook/ delivery, +// threaded through the receiver's stages so each stage stays a small, focused +// function (keeping handleHookProxy's complexity low). +type webhookRequest struct { + hook config.Webhook + cfg *config.City + scheme string +} + // handleHookProxy is the raw /hook/{name} receiver — the fourth sanctioned // non-Huma surface (alongside /svc/*), mounted on the per-city Server.mux so the // HMAC/ed25519 verifiers see the exact raw body. It deliberately sits OUTSIDE the @@ -38,102 +47,147 @@ const defaultMaxWebhookBodyBytes int64 = 5 << 20 // ADDITIONAL gate for public webhooks, never a replacement for the operator's grant // when write-auth is configured. // -// Flow: resolve webhook (404 if unknown) → R2 perimeter → E8 rate-limit (429) → -// read raw body (capped) → R1 verifier build → verify (E4) → Discord PING→PONG → -// parse + match (E5) → E8 dedup claim → dispatch (E6) via the live E0.5 seam. Every -// accept/reject decision emits a webhook.received / webhook.rejected event (E8). +// Flow (split into stages): resolve webhook (404 if unknown) → admit (R2 +// perimeter → POST-only → allowed_cidrs → bearer_env → E8 rate-limit) → read raw +// body (capped) → verify (R1 build → E4 verify → Discord PING→PONG) → dispatch +// (parse + match → dedup → E6 sink). The pre-verification reject paths that an +// unauthenticated caller fully controls (unknown name, perimeter, method, +// source/bearer denial, rate-limit) are NON-evented so a flood cannot amplify +// into per-request event/log writes; so are the pre-limiter access-gate +// operator-fault 503s (allowed_cidrs/bearer_env misconfig), which are logged +// one-shot instead. The verify/dispatch decisions past the limiter — including the +// verifier operator-fault 503 the limiter throttles — stay evented. The access +// gates sit BEFORE the limiter so a disallowed caller cannot drain the shared +// delivery bucket. func (s *Server) handleHookProxy(w http.ResponseWriter, r *http.Request) { + req, ok := s.resolveWebhookRequest(w, r) + if !ok { + return + } + if !s.admitWebhookRequest(w, r, req) { + return + } + body, ok := s.readWebhookBody(w, r, req) + if !ok { + return + } + vres, ok := s.verifyWebhook(w, r, req, body) + if !ok { + return + } + s.dispatchWebhook(w, r, req, body, vres) +} + +// resolveWebhookRequest resolves the {name} segment to a configured webhook. +// An empty or unknown name → 404, deliberately NOT evented: the route segment is +// attacker-chosen and unauthenticated, so emitting would be an event-log-flood +// amplifier and a name-existence oracle. +func (s *Server) resolveWebhookRequest(w http.ResponseWriter, r *http.Request) (webhookRequest, bool) { name := webhookNameFromPath(r.URL.Path) if name == "" { problemWebhookRouteNotFound.writeTo(w) - return + return webhookRequest{}, false } cfg := s.state.Config() hook, ok := findWebhook(cfg, name) if !ok { - // Unknown name → 404. Never leak which webhook names exist, and never - // answer with a 403-plus-detail that would confirm the route. Deliberately - // NOT evented: the route segment is attacker-chosen and unauthenticated, so - // emitting here would be an event-log-flood amplifier and a name oracle. problemWebhookRouteNotFound.writeTo(w) - return + return webhookRequest{}, false } - scheme := strings.TrimSpace(hook.Verify.Scheme) + return webhookRequest{hook: hook, cfg: cfg, scheme: strings.TrimSpace(hook.Verify.Scheme)}, true +} - // Webhooks are POST deliveries only. +// admitWebhookRequest runs the cheap pre-verification gates in the order that +// closes the amplification/existence-leak findings AND keeps a disallowed caller +// off the shared per-hook delivery bucket: the R2 perimeter FIRST (so a +// private/tenant probe gets the same 404 as an unknown route, never a 405 that +// confirms existence), then POST-only, then the operator-owned source and bearer +// gates, and ONLY THEN the E8 rate limiter. Running the access gates before the +// limiter is load-bearing: an off-network or unauthenticated flood is rejected +// without consuming a delivery token, so it cannot drain the bucket that +// legitimate provider deliveries draw from and force them into 429s. Every gate +// here is non-evented — each is a cheap, unauthenticated, attacker-fully-controlled +// reject, so eventing it would be the per-request amplification the limiter exists +// to stop (an operator misconfiguration surfaced by the access gates is the lone +// evented exception, a 503). It returns false when it has already written the +// response. +func (s *Server) admitWebhookRequest(w http.ResponseWriter, r *http.Request, req webhookRequest) bool { + // R2 perimeter on the EFFECTIVE (post pack-guard) visibility. Non-evented: the + // private/tenant 404 must be as quiet as an unknown-route 404. + visibility := strings.ToLower(strings.TrimSpace(req.hook.Publication.Visibility)) + if !webhookRequestAllowed(w, visibility, r, s.readOnly) { + return false + } + // POST-only, right after the perimeter so a non-POST probe of a private/tenant + // hook already got the existence-hiding 404. Cheap, non-evented. if r.Method != http.MethodPost { problemWebhookMethodNotAllowed.writeTo(w) - s.emitWebhookRejected(WebhookRejectedPayload{ - Webhook: hook.Name, Scheme: scheme, - Reason: reasonMethodNotAllowed, Status: http.StatusMethodNotAllowed, - }) - return + return false } - - // R2 perimeter. The effective Publication.Visibility was ALREADY capped by - // E2's pack-guard at config load (public honored only under a city - // allow_public grant; otherwise tenant). Read the post-guard value — do NOT - // re-derive trust here. - visibility := strings.ToLower(strings.TrimSpace(hook.Publication.Visibility)) - if allowed, reason := webhookRequestAllowed(w, visibility, r, s.readOnly); !allowed { - // webhookRequestAllowed already wrote the response; reason distinguishes a - // perimeter denial from a read-only refusal. - s.emitWebhookRejected(WebhookRejectedPayload{Webhook: hook.Name, Scheme: scheme, Reason: reason}) - return + // Operator-owned access controls, enforced fail-closed BEFORE the limiter so a + // disallowed source/bearer neither consumes a delivery token nor reaches the + // body read and signature verify. Their attacker-controlled denials are + // non-evented; only an operator misconfiguration (503) events. + if !s.webhookSourceAllowed(w, r, req) { + return false } - - // E8 rate-limit: per-webhook token bucket on the RESOLVED name, upstream of the - // expensive body-read + verify. The limit is operator-owned; a pack can only - // LOWER its own ceiling (clamped in EffectiveRateLimit), never raise it. - perMinute, burst := cfg.WebhookPolicy.EffectiveRateLimit(hook) - if ok, retryAfter := s.webhookLimiter.allow(hook.Name, perMinute, burst); !ok { + if !s.webhookBearerAllowed(w, r, req) { + return false + } + // E8 rate-limit on the RESOLVED name, LAST in admit so only access-passing + // requests consume the operator-owned per-hook delivery bucket, and still + // upstream of the expensive body read + signature verify it exists to throttle. + // Non-evented: eventing here would be the per-request amplification the limiter + // stops. A pack can only LOWER its own ceiling (EffectiveRateLimit), never raise it. + perMinute, burst := req.cfg.WebhookPolicy.EffectiveRateLimit(req.hook) + if ok, retryAfter := s.webhookLimiter.allow(req.hook.Name, perMinute, burst); !ok { setRetryAfter(w, retryAfter) problemWebhookRateLimited.writeTo(w) - // Deliberately NOT evented: this fires on every over-limit request, so on a - // flood it would be an un-throttled per-request event/log write on a public - // endpoint — the very amplification the limiter exists to stop. The 429 + - // Retry-After IS the signal; a persistent flood shows up in ingress metrics. - // (The other reject paths — perimeter_denied, verify_failed, operator_fault, - // dispatch_* — stay evented: they are lower-volume and diagnostically useful.) - return + return false } + return true +} - // Read the raw body under a hard cap (the signature is computed over it). +// readWebhookBody reads the raw body under a hard cap (the signature is computed +// over it, so it must be buffered whole). A too-large/unreadable body is evented +// (it is past the limiter, so bounded, and diagnostically useful). +func (s *Server) readWebhookBody(w http.ResponseWriter, r *http.Request, req webhookRequest) ([]byte, bool) { body, err := readCappedBody(w, r, s.maxWebhookBodyBytes()) - if err != nil { - var maxErr *http.MaxBytesError - if errors.As(err, &maxErr) { - problemWebhookBodyTooLarge.writeTo(w) - s.emitWebhookRejected(WebhookRejectedPayload{ - Webhook: hook.Name, Scheme: scheme, - Reason: reasonBodyTooLarge, Status: http.StatusRequestEntityTooLarge, - }) - return - } - problemWebhookBadBody.writeTo(w) + if err == nil { + return body, true + } + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + problemWebhookBodyTooLarge.writeTo(w) s.emitWebhookRejected(WebhookRejectedPayload{ - Webhook: hook.Name, Scheme: scheme, - Reason: reasonBadBody, Status: http.StatusBadRequest, + Webhook: req.hook.Name, Scheme: req.scheme, + Reason: reasonBodyTooLarge, Status: http.StatusRequestEntityTooLarge, }) - return + return nil, false } + problemWebhookBadBody.writeTo(w) + s.emitWebhookRejected(WebhookRejectedPayload{ + Webhook: req.hook.Name, Scheme: req.scheme, + Reason: reasonBadBody, Status: http.StatusBadRequest, + }) + return nil, false +} - // R1: build the verifier with an operator-owned secret / trust anchor. - verifier, secret, verr := s.buildWebhookVerifier(cfg, hook) +// verifyWebhook builds the R1 verifier and runs the E4 signature check, then +// short-circuits a verified Discord PING to a PONG. It returns ok=false (response +// already written) on an operator fault (503), a failed verification (401), or a +// handled PING; otherwise it returns the verified result. +func (s *Server) verifyWebhook(w http.ResponseWriter, r *http.Request, req webhookRequest, body []byte) (webhookverify.VerifyResult, bool) { + verifier, secret, verr := s.buildWebhookVerifier(req.cfg, req.hook) if verr != nil { // Operator fault (secret_env outside GC_WEBHOOK_*, unset, too weak; or a // jwt-jwks webhook with no operator [webhooks].jwt_policy; or a scheme // construction error) → 503, never 401: the delivery may be perfectly // authentic, we simply cannot check it. This is the R1 fail-closed contract. - log.Printf("api: webhook %q verifier unavailable: %v", hook.Name, verr) - problemWebhookVerifierUnavailable.writeTo(w) - s.emitWebhookRejected(WebhookRejectedPayload{ - Webhook: hook.Name, Scheme: scheme, - Reason: reasonOperatorFault, Status: http.StatusServiceUnavailable, BodySize: len(body), - }) - return + log.Printf("api: webhook %q verifier unavailable: %v", req.hook.Name, verr) + s.rejectWebhookOperatorFault(w, req, len(body)) + return webhookverify.VerifyResult{}, false } - vres, verifyErr := verifier.Verify(r.Context(), webhookverify.VerifyRequest{ Body: body, Header: r.Header, @@ -141,38 +195,87 @@ func (s *Server) handleHookProxy(w http.ResponseWriter, r *http.Request) { }) if verifyErr != nil { // The check could not be performed (operator fault, e.g. malformed key). - log.Printf("api: webhook %q verify error: %v", hook.Name, verifyErr) - problemWebhookVerifierUnavailable.writeTo(w) - s.emitWebhookRejected(WebhookRejectedPayload{ - Webhook: hook.Name, Scheme: scheme, - Reason: reasonOperatorFault, Status: http.StatusServiceUnavailable, BodySize: len(body), - }) - return + log.Printf("api: webhook %q verify error: %v", req.hook.Name, verifyErr) + s.rejectWebhookOperatorFault(w, req, len(body)) + return webhookverify.VerifyResult{}, false } if !vres.OK { problemWebhookUnauthorized.writeTo(w) s.emitWebhookRejected(WebhookRejectedPayload{ - Webhook: hook.Name, Scheme: scheme, + Webhook: req.hook.Name, Scheme: req.scheme, Reason: reasonVerifyFailed, Status: http.StatusUnauthorized, EventType: vres.EventType, BodySize: len(body), }) - return + return webhookverify.VerifyResult{}, false } - // Discord PING (interaction type 1) on a VERIFIED payload → PONG, no dispatch. // Ordered after verification so a forged type=1 body cannot elicit a PONG. A // protocol handshake, not a delivery, so it is neither deduped nor evented. - if strings.EqualFold(scheme, "discord-ed25519") && isDiscordPing(body) { + if strings.EqualFold(req.scheme, "discord-ed25519") && isDiscordPing(body) { writeJSONBytes(w, http.StatusOK, discordPongBody) - return + return webhookverify.VerifyResult{}, false + } + return vres, true +} + +// rejectWebhookOperatorFault writes the shared 503 verifier-unavailable response +// and emits the operator_fault rejection event. It is the POST-limiter fault path +// (verifier unavailable), so the delivery limiter already throttles a flood; the +// per-request event is bounded and diagnostically useful. The pre-limiter access +// gates use rejectWebhookAccessOperatorFault instead, which must not amplify. +func (s *Server) rejectWebhookOperatorFault(w http.ResponseWriter, req webhookRequest, bodySize int) { + problemWebhookVerifierUnavailable.writeTo(w) + s.emitWebhookRejected(WebhookRejectedPayload{ + Webhook: req.hook.Name, Scheme: req.scheme, + Reason: reasonOperatorFault, Status: http.StatusServiceUnavailable, BodySize: bodySize, + }) +} + +// rejectWebhookAccessOperatorFault writes the shared 503 operator-fault response +// for a PRE-LIMITER access gate — a misconfigured allowed_cidrs, or an unset/empty +// bearer_env on a hook that still passed config load. Unlike the post-limiter +// verifier fault above, these gates run BEFORE the delivery limiter, so an +// attacker flooding a misconfigured public hook could amplify the fault into +// unbounded per-request event/log writes (CWE-400). This path is therefore +// deliberately NON-EVENTED and its diagnostic log is one-shot per (hook, fault): +// the 503 status — still returned per request, as cheap as the other pre-limiter +// rejects — plus ingress metrics are the flood-proof operator signal, and the +// latched log names the broken hook once. faultDetail identifies the specific +// misconfiguration so a later, different fault reports again. +func (s *Server) rejectWebhookAccessOperatorFault(w http.ResponseWriter, hookName, faultDetail string) { + problemWebhookVerifierUnavailable.writeTo(w) + if s.webhookAccessFaultFirstSeen(hookName, faultDetail) { + log.Printf("api: webhook %q %s", hookName, faultDetail) } +} + +// webhookAccessFaultFirstSeen reports whether the (hook, fault) pair has not been +// reported yet, latching it so a flood reports the fault once instead of once per +// request. The key derives from the webhook name and the operator-owned +// misconfiguration, never attacker input, so the latch set stays bounded by config. +func (s *Server) webhookAccessFaultFirstSeen(hookName, faultDetail string) bool { + key := hookName + "\x00" + faultDetail + s.webhookAccessFaultMu.Lock() + defer s.webhookAccessFaultMu.Unlock() + if s.webhookAccessFaultLogged == nil { + s.webhookAccessFaultLogged = make(map[string]struct{}) + } + if _, seen := s.webhookAccessFaultLogged[key]; seen { + return false + } + s.webhookAccessFaultLogged[key] = struct{}{} + return true +} +// dispatchWebhook parses + matches the verified delivery, claims dedup, and +// routes a matched rule to the E6 sink. It owns every post-verification response. +func (s *Server) dispatchWebhook(w http.ResponseWriter, r *http.Request, req webhookRequest, body []byte, vres webhookverify.VerifyResult) { parsed, perr := webhookmatch.ParseBody(body) if perr != nil { // Authentic sender, malformed payload → 400. problemWebhookBadPayload.writeTo(w) s.emitWebhookRejected(WebhookRejectedPayload{ - Webhook: hook.Name, Scheme: scheme, + Webhook: req.hook.Name, Scheme: req.scheme, Reason: reasonBadPayload, Status: http.StatusBadRequest, EventType: vres.EventType, BodySize: len(body), }) @@ -184,13 +287,13 @@ func (s *Server) handleHookProxy(w http.ResponseWriter, r *http.Request) { DedupID: vres.DedupID, Identity: vres.Identity, Body: parsed, - }, hook.Rules) + }, req.hook.Rules) if merr != nil { // Structural arg-extraction failure on a matched rule (misconfiguration). - log.Printf("api: webhook %q match error: %v", hook.Name, merr) + log.Printf("api: webhook %q match error: %v", req.hook.Name, merr) problemInternalServerError.writeTo(w) s.emitWebhookRejected(WebhookRejectedPayload{ - Webhook: hook.Name, Scheme: scheme, + Webhook: req.hook.Name, Scheme: req.scheme, Reason: reasonMatchError, Status: http.StatusInternalServerError, EventType: vres.EventType, BodySize: len(body), }) @@ -201,7 +304,7 @@ func (s *Server) handleHookProxy(w http.ResponseWriter, r *http.Request) { // non-2xx, so a valid-but-unmatched delivery is a 2xx no-op — never a 4xx — // but it IS an accepted delivery, so it is evented as webhook.received. s.emitWebhookReceived(WebhookReceivedPayload{ - Webhook: hook.Name, Scheme: scheme, EventType: vres.EventType, + Webhook: req.hook.Name, Scheme: req.scheme, EventType: vres.EventType, DedupID: vres.DedupID, Matched: false, Dispatched: false, RuleIndex: -1, BodySize: len(body), }) @@ -223,11 +326,11 @@ func (s *Server) handleHookProxy(w http.ResponseWriter, r *http.Request) { if eventDedupID == "" { eventDedupID = webhookBodyHash(body) } - dedupKey := webhookDedupKeyFor(hook.Name, vres, body) + dedupKey := webhookDedupKeyFor(req.hook.Name, vres, body) if s.webhookDedup.seen(dedupKey) { // Duplicate: ack 2xx so the sender stops retrying, but do NOT dispatch. s.emitWebhookReceived(WebhookReceivedPayload{ - Webhook: hook.Name, Scheme: scheme, EventType: vres.EventType, + Webhook: req.hook.Name, Scheme: req.scheme, EventType: vres.EventType, DedupID: eventDedupID, Deduped: true, Matched: true, Dispatched: false, RuleIndex: match.RuleIndex, Order: match.Order, Rig: match.Rig, BodySize: len(body), }) @@ -241,7 +344,7 @@ func (s *Server) handleHookProxy(w http.ResponseWriter, r *http.Request) { s.webhookDedup.forget(dedupKey) // never acted on: let the sender retry problemWebhookDispatchUnavailable.writeTo(w) s.emitWebhookRejected(WebhookRejectedPayload{ - Webhook: hook.Name, Scheme: scheme, + Webhook: req.hook.Name, Scheme: req.scheme, Reason: reasonDispatchUnavailable, Status: http.StatusServiceUnavailable, EventType: vres.EventType, DedupID: eventDedupID, BodySize: len(body), }) @@ -254,13 +357,13 @@ func (s *Server) handleHookProxy(w http.ResponseWriter, r *http.Request) { result, rerr := webhooksink.Route(context.WithoutCancel(r.Context()), webhooksink.Deps{ Dispatcher: dispatcher, ResolveOrder: orderResolverFor(s.state), - }, webhookScopeFor(hook), match) + }, webhookScopeFor(req.hook), match) if rerr != nil { s.webhookDedup.forget(dedupKey) // genuine failure: allow the sender's retry - log.Printf("api: webhook %q dispatch failed: %v", hook.Name, rerr) + log.Printf("api: webhook %q dispatch failed: %v", req.hook.Name, rerr) problemWebhookDispatchUnavailable.writeTo(w) s.emitWebhookRejected(WebhookRejectedPayload{ - Webhook: hook.Name, Scheme: scheme, + Webhook: req.hook.Name, Scheme: req.scheme, Reason: reasonDispatchError, Status: http.StatusServiceUnavailable, EventType: vres.EventType, DedupID: eventDedupID, BodySize: len(body), }) @@ -269,7 +372,7 @@ func (s *Server) handleHookProxy(w http.ResponseWriter, r *http.Request) { if result.Dispatched { s.emitWebhookReceived(WebhookReceivedPayload{ - Webhook: hook.Name, Scheme: scheme, EventType: vres.EventType, + Webhook: req.hook.Name, Scheme: req.scheme, EventType: vres.EventType, DedupID: eventDedupID, Deduped: false, Matched: true, Dispatched: true, RuleIndex: match.RuleIndex, Order: match.Order, Rig: match.Rig, ScopedName: result.Dispatch.ScopedName, TrackingID: result.Dispatch.TrackingID, @@ -278,16 +381,16 @@ func (s *Server) handleHookProxy(w http.ResponseWriter, r *http.Request) { writeJSONBytes(w, http.StatusAccepted, webhookAcceptedBody) return } - // Refused by a sink guard (rig scope, trigger!=webhook, missing required param, - // conversation sink not yet wired). Deterministic, so release the dedup claim: - // the sender's non-2xx retry should get an honest 422, not a masked 2xx dedup. - // The detailed reason names an order/rig/param — safe to log, but the wire body - // AND the event stay generic (reason=dispatch_refused) so the public edge learns - // nothing about the city's order catalog. + // Refused by a sink guard (rig scope, public-hook exec order, trigger!=webhook, + // missing required param, conversation sink not yet wired). Deterministic, so + // release the dedup claim: the sender's non-2xx retry should get an honest 422, + // not a masked 2xx dedup. The detailed reason names an order/rig/param — safe to + // log, but the wire body AND the event stay generic (reason=dispatch_refused) so + // the public edge learns nothing about the city's order catalog. s.webhookDedup.forget(dedupKey) - log.Printf("api: webhook %q refused: %s", hook.Name, result.Reason) + log.Printf("api: webhook %q refused: %s", req.hook.Name, result.Reason) s.emitWebhookRejected(WebhookRejectedPayload{ - Webhook: hook.Name, Scheme: scheme, + Webhook: req.hook.Name, Scheme: req.scheme, Reason: reasonDispatchRefused, Status: http.StatusUnprocessableEntity, EventType: vres.EventType, DedupID: eventDedupID, BodySize: len(body), }) @@ -346,22 +449,25 @@ func findWebhook(cfg *config.City, name string) (config.Webhook, bool) { // gets a 404 (not a read-only 403 that would confirm the route exists); a public // route's existence is already known, so a read-only 403 there leaks nothing. // -// Returns (true, "") to proceed; on false it has already written the rejection and -// returns the reason enum (reasonPerimeterDenied or reasonReadOnly) for the event. -func webhookRequestAllowed(w http.ResponseWriter, visibility string, r *http.Request, apiReadOnly bool) (bool, string) { +// It returns true to proceed; on false it has already written the rejection. +// These denials are DELIBERATELY NOT evented (the caller does not emit): they are +// cheap, unauthenticated, attacker-fully-controlled reject paths, so eventing +// them would be the same event-log-flood amplifier and existence oracle that +// keeps an unknown-name 404 non-evented. +func webhookRequestAllowed(w http.ResponseWriter, visibility string, r *http.Request, apiReadOnly bool) bool { public := visibility == "public" if !public { internalProxyRequest := r.Header.Get("X-GC-Request") != "" if !isLoopbackRemoteAddr(r.RemoteAddr) && !internalProxyRequest { problemWebhookRouteNotFound.writeTo(w) - return false, reasonPerimeterDenied + return false } } if apiReadOnly { problemWebhookReadOnly.writeTo(w) - return false, reasonReadOnly + return false } - return true, "" + return true } // buildWebhookVerifier constructs the E4 verifier for a hook with an @@ -449,14 +555,18 @@ func webhookVerifierFingerprint(hook config.Webhook, opts webhookverify.Options) }, "\x00") } -// webhookScopeFor builds the E6 dispatch scope from a matched webhook. config.Webhook -// carries no rig binding today, so a rig-scoped webhook fails closed in the sink's -// R4 scoping (it declares no rig); city-scoped webhooks let the rule's own rig stand. +// webhookScopeFor builds the E6 dispatch scope from a matched webhook. It carries +// the webhook's authoritative rig binding (so a rig-scoped webhook dispatches to +// its own rig and refuses foreign rigs, R4) and its EFFECTIVE (post pack-guard) +// publication visibility (so the sink refuses to let a public hook reach the exec +// sink, R4). A city-scoped webhook lets the rule's own rig stand. func webhookScopeFor(w config.Webhook) webhooksink.WebhookScope { return webhooksink.WebhookScope{ - Name: w.Name, - Scope: w.ScopeOrDefault(), - SourceDir: w.SourceDir, + Name: w.Name, + Scope: w.ScopeOrDefault(), + Rig: strings.TrimSpace(w.Rig), + Visibility: strings.ToLower(strings.TrimSpace(w.Publication.Visibility)), + SourceDir: w.SourceDir, } } @@ -634,6 +744,10 @@ var ( status: http.StatusUnauthorized, body: []byte(`{"status":401,"title":"Unauthorized","detail":"signature verification failed"}`), } + problemWebhookForbiddenSource = problemBody{ + status: http.StatusForbidden, + body: []byte(`{"status":403,"title":"Forbidden","detail":"forbidden: source address is not permitted"}`), + } problemWebhookVerifierUnavailable = problemBody{ status: http.StatusServiceUnavailable, body: []byte(`{"status":503,"title":"Service Unavailable","detail":"webhook verifier unavailable"}`), diff --git a/internal/api/handler_webhook_test.go b/internal/api/handler_webhook_test.go index c24c504401..a9d14dc138 100644 --- a/internal/api/handler_webhook_test.go +++ b/internal/api/handler_webhook_test.go @@ -1,12 +1,14 @@ package api import ( + "bytes" "context" "crypto/ed25519" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" + "log" "net/http" "net/http/httptest" "strconv" @@ -622,6 +624,177 @@ func TestWebhookSlackDistinctBodiesSameTsBothDispatch(t *testing.T) { } } +// (#1) A rig-scoped webhook dispatches to its own rig and refuses a rule that +// targets a foreign rig — end-to-end through the receiver + sink. +func TestWebhookRigScopedOwnRigDispatchesForeignRejected(t *testing.T) { + t.Setenv("GC_WEBHOOK_GITHUB_SECRET", "rig-scoped-webhook-secret-01") + secret := []byte("rig-scoped-webhook-secret-01") + sig := githubSignature(secret, []byte(prLabeledPayload)) + hdrs := githubHeaders(sig, "rig-1") + + rigHook := func(ruleRig string) config.Webhook { + w := githubWebhook("public") + w.Scope = "rig" + w.Rig = "maintainer" + w.Rules[0].Rig = ruleRig + return w + } + order := prReviewOrder() + order.Rig = "maintainer" + + t.Run("own rig dispatches", func(t *testing.T) { + disp := firedDispatcher() + state := newWebhookState(t, rigHook(""), order, disp) // empty rule rig inherits the webhook's + h := newTestCityHandler(t, state) + rec := postHook(t, h, state, "github", prLabeledPayload, "203.0.113.7:443", hdrs) + if rec.Code != http.StatusAccepted { + t.Fatalf("own-rig delivery = %d, want 202 (body %s)", rec.Code, rec.Body.String()) + } + if disp.count() != 1 { + t.Fatalf("own-rig dispatch count = %d, want 1", disp.count()) + } + }) + + t.Run("foreign rig refused", func(t *testing.T) { + disp := firedDispatcher() + state := newWebhookState(t, rigHook("intruder"), order, disp) + h := newTestCityHandler(t, state) + rec := postHook(t, h, state, "github", prLabeledPayload, "203.0.113.7:443", hdrs) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("foreign-rig delivery = %d, want 422", rec.Code) + } + if disp.count() != 0 { + t.Fatalf("foreign-rig target must never dispatch, got %d", disp.count()) + } + }) +} + +// (#3) A public webhook that targets an exec (sh -c) order is refused end-to-end: +// public deliveries are limited to formula orders (the removed RCE sink). +func TestWebhookPublicExecOrderRefused(t *testing.T) { + t.Setenv("GC_WEBHOOK_GITHUB_SECRET", "public-exec-webhook-secret-1") + secret := []byte("public-exec-webhook-secret-1") + + execOrder := orders.Order{Name: prReviewOrderName, Trigger: "webhook", Exec: "deploy.sh"} + disp := firedDispatcher() + state := newWebhookState(t, githubWebhook("public"), execOrder, disp) + h := newTestCityHandler(t, state) + + sig := githubSignature(secret, []byte(prLabeledPayload)) + rec := postHook(t, h, state, "github", prLabeledPayload, "203.0.113.7:443", githubHeaders(sig, "exec-1")) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("public→exec delivery = %d, want 422 (body %s)", rec.Code, rec.Body.String()) + } + if disp.count() != 0 { + t.Fatalf("a public webhook must never fire an exec order, got %d", disp.count()) + } +} + +// (#2) An operator-declared bearer_env token is enforced alongside the signature: +// a valid signature with a missing/wrong bearer is 401; the correct bearer passes. +func TestWebhookBearerEnvEnforced(t *testing.T) { + t.Setenv("GC_WEBHOOK_GITHUB_SECRET", "bearer-webhook-signing-secret") + t.Setenv("GC_WEBHOOK_GH_BEARER", "s3cr3t-bearer-token-value") + secret := []byte("bearer-webhook-signing-secret") + + hook := githubWebhook("public") + hook.Verify.BearerEnv = "GC_WEBHOOK_GH_BEARER" + sig := githubSignature(secret, []byte(prLabeledPayload)) + + // Valid signature, NO bearer → 401. + disp := firedDispatcher() + state := newWebhookState(t, hook, prReviewOrder(), disp) + h := newTestCityHandler(t, state) + if rec := postHook(t, h, state, "github", prLabeledPayload, "203.0.113.7:443", githubHeaders(sig, "b-1")); rec.Code != http.StatusUnauthorized { + t.Fatalf("valid sig, no bearer = %d, want 401", rec.Code) + } + if disp.count() != 0 { + t.Fatalf("missing bearer must not dispatch, got %d", disp.count()) + } + + // Wrong bearer → 401. + wrong := githubHeaders(sig, "b-2") + wrong["Authorization"] = "Bearer not-the-token" + if rec := postHook(t, h, state, "github", prLabeledPayload, "203.0.113.7:443", wrong); rec.Code != http.StatusUnauthorized { + t.Fatalf("wrong bearer = %d, want 401", rec.Code) + } + + // Correct bearer → dispatch. + ok := githubHeaders(sig, "b-3") + ok["Authorization"] = "Bearer s3cr3t-bearer-token-value" + if rec := postHook(t, h, state, "github", prLabeledPayload, "203.0.113.7:443", ok); rec.Code != http.StatusAccepted { + t.Fatalf("correct bearer = %d, want 202", rec.Code) + } + if disp.count() != 1 { + t.Fatalf("correct bearer dispatch count = %d, want 1", disp.count()) + } +} + +// (#2) An operator-declared allowed_cidrs allowlist is enforced against the direct +// connection address: an in-range source dispatches, an out-of-range source is 403. +func TestWebhookAllowedCIDRsEnforced(t *testing.T) { + t.Setenv("GC_WEBHOOK_GITHUB_SECRET", "cidr-webhook-signing-secret-1") + secret := []byte("cidr-webhook-signing-secret-1") + + hook := githubWebhook("public") + hook.Verify.AllowedCIDRs = []string{"203.0.113.0/24"} + sig := githubSignature(secret, []byte(prLabeledPayload)) + + disp := firedDispatcher() + state := newWebhookState(t, hook, prReviewOrder(), disp) + h := newTestCityHandler(t, state) + + // Out-of-range source → 403, no dispatch. + if rec := postHook(t, h, state, "github", prLabeledPayload, "198.51.100.10:9000", githubHeaders(sig, "c-1")); rec.Code != http.StatusForbidden { + t.Fatalf("out-of-range source = %d, want 403", rec.Code) + } + if disp.count() != 0 { + t.Fatalf("out-of-range source must not dispatch, got %d", disp.count()) + } + + // In-range source → dispatch. + if rec := postHook(t, h, state, "github", prLabeledPayload, "203.0.113.7:443", githubHeaders(sig, "c-2")); rec.Code != http.StatusAccepted { + t.Fatalf("in-range source = %d, want 202", rec.Code) + } + if disp.count() != 1 { + t.Fatalf("in-range source dispatch count = %d, want 1", disp.count()) + } +} + +// (#4) A Slack rule that selects a payload event type (event = "message") matches +// only when the verified body carries that nested event.type — proving the event +// type is derived from the body, not left empty. +func TestWebhookSlackEventTypeRuleMatches(t *testing.T) { + t.Setenv("GC_WEBHOOK_SLACK_SECRET", "slack-eventtype-secret-abcdef") + secret := []byte("slack-eventtype-secret-abcdef") + + hook := slackWebhook() + hook.Rules = []config.WebhookRule{{Event: "message", Order: prReviewOrderName}} + disp := firedDispatcher() + state := newWebhookState(t, hook, prReviewOrder(), disp) + h := newTestCityHandler(t, state) + ts := strconv.FormatInt(time.Now().Unix(), 10) + + // event.type=message → matches the event="message" rule → dispatch. + msg := `{"type":"event_callback","event":{"type":"message"}}` + if rec := postHook(t, h, state, "slack", msg, "203.0.113.7:443", slackHeaders(secret, ts, msg)); rec.Code != http.StatusAccepted { + t.Fatalf("slack event.type=message = %d, want 202 (body %s)", rec.Code, rec.Body.String()) + } + if disp.count() != 1 { + t.Fatalf("event.type=message must dispatch, got %d", disp.count()) + } + + // A different event type → no rule matches → 2xx no-op, no new dispatch. + other := `{"type":"event_callback","event":{"type":"reaction_added"}}` + rec := postHook(t, h, state, "slack", other, "203.0.113.7:443", slackHeaders(secret, ts, other)) + if rec.Code < 200 || rec.Code >= 300 { + t.Fatalf("unmatched slack event = %d, want 2xx no-op", rec.Code) + } + if disp.count() != 1 { + t.Fatalf("a non-matching event type must not dispatch, count = %d (want still 1)", disp.count()) + } +} + // (FIX 6) The built verifier is memoized per webhook so the jwt-jwks JWKS cache // persists across deliveries (fetched once, not rebuilt+refetched per request). // Two builds with an unchanged config fingerprint return the SAME verifier @@ -704,6 +877,139 @@ func TestWebhookRateLimitReturns429(t *testing.T) { } } +// The operator-owned access gates (allowed_cidrs, bearer_env) run BEFORE the E8 +// rate limiter, so an off-network or unauthenticated flood is rejected without +// consuming the shared per-hook delivery bucket — and those denials are +// non-evented (a flood must not amplify into per-request events). A burst of +// denied requests therefore leaves the single delivery token intact for a +// subsequent legitimate delivery. +func TestWebhookAccessDenialsAreNonEventedAndSpareDeliveryBucket(t *testing.T) { + t.Run("off-CIDR flood spares the bucket", func(t *testing.T) { + t.Setenv("GC_WEBHOOK_GITHUB_SECRET", "access-order-cidr-secret-01") + secret := []byte("access-order-cidr-secret-01") + + hook := githubWebhook("public") + hook.Verify.AllowedCIDRs = []string{"203.0.113.0/24"} + disp := firedDispatcher() + state := newWebhookState(t, hook, prReviewOrder(), disp) + // One delivery per minute, burst 1: a single token guards the bucket. + state.cfg.WebhookPolicy.RateLimit = &config.WebhookRateLimitConfig{PerMinute: 1, Burst: 1} + h, srv := newWebhookHandler(t, state) + now := time.Now() + srv.webhookLimiter.now = func() time.Time { return now } // freeze: no refill + + sig := githubSignature(secret, []byte(prLabeledPayload)) + // A burst of off-allowlist deliveries: each is a 403 and must NOT consume a token. + for i := 0; i < 3; i++ { + rec := postHook(t, h, state, "github", prLabeledPayload, "198.51.100.10:9000", githubHeaders(sig, "cidr-"+strconv.Itoa(i))) + if rec.Code != http.StatusForbidden { + t.Fatalf("off-CIDR delivery %d = %d, want 403", i, rec.Code) + } + } + if disp.count() != 0 { + t.Fatalf("off-CIDR deliveries must not dispatch, got %d", disp.count()) + } + // The denied burst emits no events (non-evented, no amplification). + if rejs := webhookRejectedEvents(t, state); len(rejs) != 0 { + t.Errorf("off-CIDR denials emitted %d rejected events, want 0 (non-evented)", len(rejs)) + } + // A legitimate in-CIDR delivery still has its token → dispatches, proving the + // off-CIDR flood never drained the shared bucket. + rec := postHook(t, h, state, "github", prLabeledPayload, "203.0.113.7:443", githubHeaders(sig, "cidr-ok")) + if rec.Code != http.StatusAccepted { + t.Fatalf("in-CIDR delivery after off-CIDR flood = %d, want 202 (bucket must be intact)", rec.Code) + } + if disp.count() != 1 { + t.Fatalf("in-CIDR dispatch count = %d, want 1", disp.count()) + } + }) + + t.Run("bad-bearer flood spares the bucket", func(t *testing.T) { + t.Setenv("GC_WEBHOOK_GITHUB_SECRET", "access-order-bearer-secret-01") + t.Setenv("GC_WEBHOOK_GH_BEARER", "the-real-bearer-token") + secret := []byte("access-order-bearer-secret-01") + + hook := githubWebhook("public") + hook.Verify.BearerEnv = "GC_WEBHOOK_GH_BEARER" + disp := firedDispatcher() + state := newWebhookState(t, hook, prReviewOrder(), disp) + state.cfg.WebhookPolicy.RateLimit = &config.WebhookRateLimitConfig{PerMinute: 1, Burst: 1} + h, srv := newWebhookHandler(t, state) + now := time.Now() + srv.webhookLimiter.now = func() time.Time { return now } + + sig := githubSignature(secret, []byte(prLabeledPayload)) + // A burst of wrong-bearer deliveries: each is a 401 and must NOT consume a token. + for i := 0; i < 3; i++ { + hdrs := githubHeaders(sig, "bearer-"+strconv.Itoa(i)) + hdrs["Authorization"] = "Bearer not-the-token" + rec := postHook(t, h, state, "github", prLabeledPayload, "203.0.113.7:443", hdrs) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("bad-bearer delivery %d = %d, want 401", i, rec.Code) + } + } + if disp.count() != 0 { + t.Fatalf("bad-bearer deliveries must not dispatch, got %d", disp.count()) + } + if rejs := webhookRejectedEvents(t, state); len(rejs) != 0 { + t.Errorf("bad-bearer denials emitted %d rejected events, want 0 (non-evented)", len(rejs)) + } + // The correct bearer still has its token → dispatches. + ok := githubHeaders(sig, "bearer-ok") + ok["Authorization"] = "Bearer the-real-bearer-token" + rec := postHook(t, h, state, "github", prLabeledPayload, "203.0.113.7:443", ok) + if rec.Code != http.StatusAccepted { + t.Fatalf("correct-bearer delivery after bad-bearer flood = %d, want 202 (bucket must be intact)", rec.Code) + } + if disp.count() != 1 { + t.Fatalf("correct-bearer dispatch count = %d, want 1", disp.count()) + } + }) +} + +// A misconfigured PUBLIC hook whose bearer_env names an UNSET operator var passes +// config load (load validates the var name, not that it is set) but faults at the +// pre-limiter bearer gate on every delivery. Because that gate runs BEFORE the +// delivery limiter, eventing or logging the fault per request would be a CWE-400 +// amplifier — an unauthenticated flood could drive unbounded event-bus and log +// writes. The fault must be non-evented and logged one-shot while still returning +// a 503 per request. +func TestWebhookAccessGateOperatorFaultFloodIsNonEventedAndLoggedOnce(t *testing.T) { + t.Setenv("GC_WEBHOOK_GITHUB_SECRET", "op-fault-flood-signing-secret-1") + // Deliberately leave GC_WEBHOOK_GH_BEARER unset so the bearer gate faults. + hook := githubWebhook("public") + hook.Verify.BearerEnv = "GC_WEBHOOK_GH_BEARER" + disp := firedDispatcher() + state := newWebhookState(t, hook, prReviewOrder(), disp) + h := newTestCityHandler(t, state) + + // Capture logs to prove the diagnostic is one-shot, not once-per-request. + var logBuf bytes.Buffer + prevOut := log.Writer() + log.SetOutput(&logBuf) + t.Cleanup(func() { log.SetOutput(prevOut) }) + + sig := githubSignature([]byte("op-fault-flood-signing-secret-1"), []byte(prLabeledPayload)) + const flood = 5 + for i := 0; i < flood; i++ { + rec := postHook(t, h, state, "github", prLabeledPayload, "203.0.113.7:443", githubHeaders(sig, "of-"+strconv.Itoa(i))) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("operator-fault delivery %d = %d, want 503", i, rec.Code) + } + } + if disp.count() != 0 { + t.Fatalf("operator-fault deliveries must not dispatch, got %d", disp.count()) + } + // CWE-400: the flood must NOT amplify into per-request webhook.rejected events. + if rejs := webhookRejectedEvents(t, state); len(rejs) != 0 { + t.Errorf("operator-fault flood emitted %d rejected events, want 0 (non-evented)", len(rejs)) + } + // ...and the operator diagnostic is logged exactly once across the flood. + if got := strings.Count(logBuf.String(), "bearer_env"); got != 1 { + t.Errorf("operator-fault flood logged the fault %d times, want exactly 1 (one-shot); log:\n%s", got, logBuf.String()) + } +} + // (E8-c) A pack cannot raise its own rate limit above the operator ceiling: a // pack-contributed webhook with a huge MaxPerMinute is still limited at the tiny // operator ceiling and 429s on the second back-to-back delivery. @@ -811,7 +1117,7 @@ func TestWebhookRejectedEventReasons(t *testing.T) { } }) - t.Run("perimeter denial", func(t *testing.T) { + t.Run("perimeter denial is non-evented (no amplification)", func(t *testing.T) { t.Setenv("GC_WEBHOOK_GITHUB_SECRET", "top-secret-webhook-key-pd") disp := firedDispatcher() // A private webhook denies an external (non-loopback) delivery at the perimeter. @@ -825,13 +1131,46 @@ func TestWebhookRejectedEventReasons(t *testing.T) { if disp.count() != 0 { t.Fatalf("perimeter denial must not dispatch, count = %d", disp.count()) } - rej := lastWebhookRejected(t, state) - if rej.Reason != reasonPerimeterDenied { - t.Errorf("reason = %q, want %q", rej.Reason, reasonPerimeterDenied) + // The perimeter reject is a cheap, unauthenticated, attacker-controlled path, + // so it must NOT emit an event (the amplification the finding flagged). + if rejs := webhookRejectedEvents(t, state); len(rejs) != 0 { + t.Errorf("perimeter denial emitted %d rejected events, want 0 (non-evented)", len(rejs)) } }) } +// A non-POST request to a private/tenant hook is rejected by the visibility +// perimeter with a 404 (hiding existence) BEFORE the method check — never a 405 +// that would confirm the route — and the reject is non-evented. +func TestWebhookMethodOrderingHidesPrivateExistence(t *testing.T) { + t.Setenv("GC_WEBHOOK_GITHUB_SECRET", "top-secret-webhook-key-mo") + disp := firedDispatcher() + state := newWebhookState(t, githubWebhook("private"), prReviewOrder(), disp) + h := newTestCityHandler(t, state) + + // External GET to a private hook → 404 (perimeter), not 405. + req := httptest.NewRequest(http.MethodGet, cityURL(state, "/hook/github"), nil) + req.RemoteAddr = "198.51.100.10:9000" + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("external non-POST to private hook = %d, want 404 (perimeter before method)", rec.Code) + } + + // A loopback GET passes the perimeter and then hits the POST-only check (405), + // which is non-evented. + loReq := httptest.NewRequest(http.MethodGet, cityURL(state, "/hook/github"), nil) + loReq.RemoteAddr = "127.0.0.1:9000" + loRec := httptest.NewRecorder() + h.ServeHTTP(loRec, loReq) + if loRec.Code != http.StatusMethodNotAllowed { + t.Fatalf("loopback non-POST = %d, want 405", loRec.Code) + } + if rejs := webhookRejectedEvents(t, state); len(rejs) != 0 { + t.Errorf("method/perimeter rejects emitted %d events, want 0 (non-evented)", len(rejs)) + } +} + // (E8-f) No secret, signature, or raw body ever appears in an emitted event. func TestWebhookEventsNeverLeakSecrets(t *testing.T) { const secretStr = "top-secret-webhook-key-leak" diff --git a/internal/api/server.go b/internal/api/server.go index 6b8cfd7b20..d98a910812 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -123,6 +123,20 @@ type Server struct { webhookVerifiersMu sync.Mutex webhookVerifiers map[string]cachedWebhookVerifier + // webhookAccessFaultLogged latches which pre-limiter access-gate operator + // faults (a misconfigured allowed_cidrs, or an unset/empty bearer_env on a + // hook that still passes config load) have already been reported, so a flood + // against a misconfigured public hook logs the fault ONCE, not once per + // request. These gates run BEFORE the delivery limiter, so — unlike the + // limiter-throttled verifier fault — an unbounded per-request log/event here + // would be the CWE-400 amplifier the receiver exists to avoid; the 503 itself + // is still returned per request (as cheap as the other pre-limiter rejects) + // and is deliberately non-evented. Keyed by (webhook name, fault detail) so a + // different or changed misconfiguration reports again; keys derive from + // operator config, never attacker input, so the set is bounded by config. + webhookAccessFaultMu sync.Mutex + webhookAccessFaultLogged map[string]struct{} + // webhookMaxBody overrides the /hook/ request body cap in tests. Zero uses // defaultMaxWebhookBodyBytes. webhookMaxBody int64 diff --git a/internal/api/webhook_access.go b/internal/api/webhook_access.go new file mode 100644 index 0000000000..f10ddf0801 --- /dev/null +++ b/internal/api/webhook_access.go @@ -0,0 +1,119 @@ +package api + +import ( + "crypto/subtle" + "fmt" + "net" + "net/http" + "net/netip" + "os" + "strings" + + "github.com/gastownhall/gascity/internal/config" +) + +// webhookSourceAllowed enforces a hook's operator-declared allowed_cidrs source +// allowlist (security review finding #2 — the documented control was previously a +// no-op). It matches the DIRECT connection address (RemoteAddr) against the +// allowlist and deliberately does NOT trust X-Forwarded-For, mirroring the +// supervisor's remote_addr_class policy, which classifies the peer address and +// never a forwarded header. An operator using this control must therefore deploy +// so the supervisor observes the real source address (e.g. via the PROXY +// protocol). An empty allowlist is a no-op; a malformed allowlist is an operator +// fault that fails CLOSED (503), never open. It returns false once it has written +// the response. +func (s *Server) webhookSourceAllowed(w http.ResponseWriter, r *http.Request, req webhookRequest) bool { + cidrs := req.hook.Verify.AllowedCIDRs + if len(cidrs) == 0 { + return true + } + prefixes, err := config.ParseWebhookCIDRs(cidrs) + if err != nil { + // Load-time validation should reject a malformed allowlist; if one still + // reaches here, fail closed rather than silently skipping the control. This + // runs before the limiter, so the fault is non-evented and logged one-shot + // (rejectWebhookAccessOperatorFault) to avoid a CWE-400 flood amplifier. + s.rejectWebhookAccessOperatorFault(w, req.hook.Name, fmt.Sprintf("allowed_cidrs invalid: %v", err)) + return false + } + if ip, ok := webhookRemoteIP(r.RemoteAddr); ok { + for _, p := range prefixes { + if p.Contains(ip) { + return true + } + } + } + // Off-allowlist source → 403, deliberately NON-evented. Like the perimeter and + // rate-limit rejects, the caller fully controls their source address, so this + // gate runs before the limiter (it must not consume a delivery token) and + // eventing it per request would be the flood amplifier the receiver avoids. + problemWebhookForbiddenSource.writeTo(w) + return false +} + +// webhookRemoteIP extracts the connection's IP from a RemoteAddr ("host:port" or +// a bare host), returning ok=false when it cannot be parsed — which the caller +// treats as not-allowed (fail closed). +func webhookRemoteIP(remoteAddr string) (netip.Addr, bool) { + host := strings.TrimSpace(remoteAddr) + if host == "" { + return netip.Addr{}, false + } + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + host = strings.Trim(host, "[]") + addr, err := netip.ParseAddr(host) + if err != nil { + return netip.Addr{}, false + } + // Unmap so a 4-in-6 form (::ffff:1.2.3.4) matches an IPv4 allowlist prefix. + return addr.Unmap(), true +} + +// webhookBearerAllowed enforces a hook's optional operator-owned bearer_env token +// alongside the signature (security review finding #2 — the documented control +// was previously a no-op). When bearer_env is set, the resolved token must be +// present and equal (constant-time) to the request's "Authorization: Bearer +// ". bearer_env is validated at config load to live in the GC_WEBHOOK_* +// operator namespace, so a pack cannot point it at an ambient variable. An +// unset/empty bearer_env variable is an operator fault (503, fail closed); a +// missing or mismatched token is a 401. Empty bearer_env is a no-op. +func (s *Server) webhookBearerAllowed(w http.ResponseWriter, r *http.Request, req webhookRequest) bool { + env := strings.TrimSpace(req.hook.Verify.BearerEnv) + if env == "" { + return true + } + expected, ok := os.LookupEnv(env) + if !ok || strings.TrimSpace(expected) == "" { + // Unset/empty bearer_env is an operator fault (503, fail closed). It runs + // before the limiter, so it is non-evented and logged one-shot + // (rejectWebhookAccessOperatorFault) to avoid a CWE-400 flood amplifier. + s.rejectWebhookAccessOperatorFault(w, req.hook.Name, fmt.Sprintf("bearer_env %q is unset or empty", env)) + return false + } + provided := bearerToken(r.Header.Get("Authorization")) + if subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 { + // Missing/wrong bearer → 401, deliberately NON-evented. The caller fully + // controls the Authorization header, so this gate runs before the limiter (it + // must not consume a delivery token) and eventing it per request would amplify + // a flood. An unset/empty bearer_env above is an operator fault (503) that is + // likewise pre-limiter, so it too stays non-evented (one-shot logged) rather + // than amplifying a flood into per-request writes. + problemWebhookUnauthorized.writeTo(w) + return false + } + return true +} + +// bearerToken extracts the token from an "Authorization: Bearer " header, +// returning "" when the header is absent or is not a bearer credential. The +// scheme name is matched case-insensitively per RFC 7235. +func bearerToken(authHeader string) string { + const scheme = "Bearer " + h := strings.TrimSpace(authHeader) + if len(h) >= len(scheme) && strings.EqualFold(h[:len(scheme)], scheme) { + return strings.TrimSpace(h[len(scheme):]) + } + return "" +} diff --git a/internal/api/webhook_dedup.go b/internal/api/webhook_dedup.go index 551f4274c7..565961d907 100644 --- a/internal/api/webhook_dedup.go +++ b/internal/api/webhook_dedup.go @@ -17,7 +17,12 @@ const defaultWebhookDedupTTL = 30 * time.Minute // webhookDedupCacheMaxEntries caps live entries so a flood of unique delivery ids // cannot grow the map unbounded between TTL sweeps. Over cap, seen evicts expired -// entries first and then the soonest-expiring, mirroring idempotencyCache. +// entries first and then the soonest-expiring OTHER entry of the hook whose +// insertion overflowed the cap, so a flood only ever shrinks its own replay +// window. The just-claimed key is never evicted, so the cap is soft by at most +// one retained claim per co-resident hook — bounded, because hook names are +// configured and entries expire — rather than ever dropping a delivery seen just +// promised to track. const webhookDedupCacheMaxEntries = 8192 // webhookDedupCache is the E8 delivery-idempotency store: a bounded, TTL'd set of @@ -57,7 +62,9 @@ func (c *webhookDedupCache) clock() time.Time { // seen atomically reports whether key was already recorded within the TTL. On a // first sighting it records key (claiming the delivery) and returns false; on a // live duplicate it returns true without extending the entry. An expired entry is -// treated as unseen and re-recorded. +// treated as unseen and re-recorded. A false return guarantees key stays retained +// even when the shared cap is already saturated by other hooks, so the caller can +// dispatch knowing a replay of the same delivery will dedup rather than re-fire. func (c *webhookDedupCache) seen(key string) bool { c.mu.Lock() defer c.mu.Unlock() @@ -69,7 +76,7 @@ func (c *webhookDedupCache) seen(key string) bool { delete(c.entries, key) // expired; fall through and re-record } c.entries[key] = now.Add(c.ttl) - c.enforceCapLocked(now) + c.enforceCapLocked(now, key) return false } @@ -89,9 +96,25 @@ func (c *webhookDedupCache) clear() { c.entries = make(map[string]time.Time) } -// enforceCapLocked keeps the map under c.max: expired entries first, then the -// soonest-expiring, until at or below the cap. Must hold c.mu. -func (c *webhookDedupCache) enforceCapLocked(now time.Time) { +// enforceCapLocked keeps the map near c.max: it drops expired entries first, +// then — while still over cap — evicts the soonest-expiring entry belonging to +// insertedHook OTHER THAN insertedKey, the hook whose just-recorded delivery +// pushed the map over the cap. Charging the overflow to the inserting hook means +// a high-volume webhook can only shrink ITS OWN replay window under pressure; a +// flood on one hook can never evict a quieter co-resident hook's entry — not even +// one that currently holds the most entries. The shared per-city cap must not let +// one hook erode another's replay protection (the schemes without a signed +// timestamp rely on this window). +// +// insertedKey itself is never evicted here: seen has already returned false and +// the webhook handler dispatches on that promise, so dropping the fresh key would +// dispatch an untracked delivery and let its replay re-fire. When the inserting +// hook holds only that fresh key while other hooks fill the cap, the map is left +// one entry over cap rather than breaking either the retention or the +// neighbor-protection invariant — a bounded soft overshoot (at most one retained +// claim per live co-resident hook; hook names are configured and entries expire), +// never unbounded growth. Must hold c.mu. +func (c *webhookDedupCache) enforceCapLocked(now time.Time, insertedKey string) { if len(c.entries) <= c.max { return } @@ -100,22 +123,47 @@ func (c *webhookDedupCache) enforceCapLocked(now time.Time) { delete(c.entries, k) } } + insertedHook := webhookDedupHookOf(insertedKey) for len(c.entries) > c.max { - var oldestKey string - var oldest time.Time - for k, exp := range c.entries { - if oldestKey == "" || exp.Before(oldest) { - oldestKey = k - oldest = exp - } - } - if oldestKey == "" { + if !c.evictFromHookLocked(insertedHook, insertedKey) { return } - delete(c.entries, oldestKey) } } +// evictFromHookLocked deletes the soonest-expiring entry belonging to hook, +// skipping protectKey so the just-claimed delivery is never the victim. It +// returns false when hook has no other entry left to evict, so the caller stops +// rather than spinning (leaving the map a bounded amount over cap). Must hold +// c.mu. Eviction runs only on a cap overflow, so the O(n) scan is bounded and +// rare. +func (c *webhookDedupCache) evictFromHookLocked(hook, protectKey string) bool { + var victimKey string + var victimExp time.Time + for k, exp := range c.entries { + if k == protectKey || webhookDedupHookOf(k) != hook { + continue + } + if victimKey == "" || exp.Before(victimExp) { + victimKey, victimExp = k, exp + } + } + if victimKey == "" { + return false + } + delete(c.entries, victimKey) + return true +} + +// webhookDedupHookOf returns the hook-name prefix of a dedup key (the segment +// before the NUL separator written by webhookDedupKey). +func webhookDedupHookOf(key string) string { + if i := strings.IndexByte(key, 0); i >= 0 { + return key[:i] + } + return key +} + // webhookDedupKey namespaces a delivery id under its webhook so two webhooks that // share a delivery-id value (e.g. both counting from 1) never collide. func webhookDedupKey(hook, dedupID string) string { diff --git a/internal/api/webhook_dedup_test.go b/internal/api/webhook_dedup_test.go index 63d654dfab..0c72972b25 100644 --- a/internal/api/webhook_dedup_test.go +++ b/internal/api/webhook_dedup_test.go @@ -25,6 +25,119 @@ func TestWebhookDedupCache_SeenAndForget(t *testing.T) { } } +// A high-volume webhook that overflows the shared per-city cap must evict its +// OWN soonest-expiring entries, never a quieter co-resident hook's — otherwise a +// flood erodes another hook's replay window (schemes without a signed timestamp +// depend on that window). +func TestWebhookDedupCache_FloodEvictsOwnHookNotNeighbor(t *testing.T) { + c := newWebhookDedupCache(time.Hour) + c.max = 4 // small cap so the flood overflows quickly + + quiet := webhookDedupKey("quiet", "only-one") + if c.seen(quiet) { + t.Fatal("first sight of the quiet hook must be unseen") + } + + // Flood a noisy hook well past the cap. + for i := 0; i < 50; i++ { + c.seen(webhookDedupKey("noisy", fmt.Sprintf("d-%d", i))) + } + + // The quiet hook's single entry must survive: seeing it again is a duplicate. + if !c.seen(quiet) { + t.Fatal("the quiet hook's replay entry was evicted by the noisy hook's flood") + } + // The cap is still honored. + if len(c.entries) > c.max { + t.Fatalf("cache holds %d entries, over cap %d", len(c.entries), c.max) + } +} + +// A hook that already holds most of the shared cap must keep ALL of its replay +// entries when an unrelated hook then floods the cache with unique deliveries. +// The overflow is charged to the hook doing the flooding, never to whichever +// hook happens to hold the most entries — otherwise the flooder silently erodes +// a quiet-but-busy neighbor's replay window. This is the ordering the earlier +// "flood evicts own hook" test misses: here the eventual victim becomes the +// busiest hook FIRST, then the neighbor floods. +func TestWebhookDedupCache_BusiestHookSurvivesNeighborFlood(t *testing.T) { + c := newWebhookDedupCache(time.Hour) + c.max = 8 + + // Hook A fills the cache to the cap, making it the busiest hook. + aKeys := make([]string, c.max) + for i := range aKeys { + aKeys[i] = webhookDedupKey("hook-a", fmt.Sprintf("a-%d", i)) + if c.seen(aKeys[i]) { + t.Fatalf("hook-a delivery %d: first sight must be unseen", i) + } + } + + // An unrelated hook now floods the shared cache with unique deliveries. + for i := 0; i < 100; i++ { + c.seen(webhookDedupKey("hook-b", fmt.Sprintf("b-%d", i))) + } + + // Every one of hook A's original replay keys must still read as a duplicate: + // the neighbor's flood must not have evicted any of A's entries. + for i, k := range aKeys { + if !c.seen(k) { + t.Fatalf("hook-a replay key %d was evicted by hook-b's flood — a neighbor's traffic must not erode this hook's replay window", i) + } + } + // The cap is soft by at most the flooder's single retained just-claimed key: + // hook A holds the whole cap, so hook B's latest claim (which seen() must not + // evict) sits one entry over. Never more than that here — the overshoot is + // bounded by the live co-resident hook count, not unbounded. + if len(c.entries) > c.max+1 { + t.Fatalf("cache holds %d entries, over the soft cap %d", len(c.entries), c.max+1) + } +} + +// A hook's very first delivery must stay tracked even when a neighbor has already +// saturated the shared cap. The overflow policy charges eviction to the inserting +// hook, but it must never evict that hook's just-claimed key: seen() has already +// returned false and the handler will dispatch on that promise, so dropping the +// key would dispatch an untracked delivery and let its replay re-fire. Hook A +// fills the cap; hook B then sends one delivery (claimed, retained) whose +// duplicate must dedup. This is the cap-saturation ordering the neighbor-flood +// tests miss: there the victim already holds entries, here it holds none yet. +func TestWebhookDedupCache_FirstClaimRetainedUnderSaturatedCap(t *testing.T) { + c := newWebhookDedupCache(time.Hour) + c.max = 8 + + // Hook A saturates the shared cap. + aKeys := make([]string, c.max) + for i := range aKeys { + aKeys[i] = webhookDedupKey("hook-a", fmt.Sprintf("a-%d", i)) + if c.seen(aKeys[i]) { + t.Fatalf("hook-a delivery %d: first sight must be unseen", i) + } + } + + // Hook B's first delivery lands into an already-full cache. + bKey := webhookDedupKey("hook-b", "b-only") + if c.seen(bKey) { + t.Fatal("hook-b's first delivery must be unseen") + } + // The just-claimed key must be retained: a duplicate of it dedups rather than + // dispatching an untracked replay. + if !c.seen(bKey) { + t.Fatal("hook-b's first claim was evicted under cap saturation — its replay would dispatch untracked") + } + // Neighbor protection still holds: none of hook A's replay entries were dropped + // to make room for hook B's claim. + for i, k := range aKeys { + if !c.seen(k) { + t.Fatalf("hook-a replay key %d was evicted — a co-resident hook's first claim must not cost a neighbor its replay window", i) + } + } + // The cap is soft by exactly the one retained fresh claim, never unbounded. + if len(c.entries) > c.max+1 { + t.Fatalf("cache holds %d entries, over the soft cap %d", len(c.entries), c.max+1) + } +} + func TestWebhookDedupCache_Clear(t *testing.T) { c := newWebhookDedupCache(time.Hour) k := webhookDedupKey("h", "1") diff --git a/internal/api/webhook_events.go b/internal/api/webhook_events.go index 55d08ef84b..95fb772d52 100644 --- a/internal/api/webhook_events.go +++ b/internal/api/webhook_events.go @@ -4,26 +4,35 @@ import "github.com/gastownhall/gascity/internal/events" // Webhook rejection reason enum. These are the stable strings carried on // WebhookRejectedPayload.Reason so operators can alert/aggregate on a rejection -// class without parsing free text. The security-relevant classes the design and -// red-team call out (perimeter_denied, read_only, verify_failed, operator_fault, -// rate_limited, dispatch_refused) are here alongside the operational ones -// (method_not_allowed, body_too_large, bad_body, bad_payload, match_error, -// dispatch_unavailable/dispatch_error) that keep the receiver debuggable. +// class without parsing free text. The evented classes are the ones that are +// bounded and diagnostically useful: the verify decision (verify_failed), the +// operator-misconfiguration signal (operator_fault), and the dispatch/payload +// outcomes past the limiter (bad_body, body_too_large, bad_payload, match_error, +// dispatch_refused, dispatch_unavailable, dispatch_error). // -// Notes on two design decisions: -// - An unresolved route (unknown webhook name) is intentionally NOT evented: -// the route segment is chosen by an unauthenticated caller, so emitting there -// would be an event-log-flood amplification vector and a name-existence oracle -// (it would also violate R2's "never confirm which hooks exist"). The receiver -// 404s such probes silently, so there is no unknown_webhook reason. +// Notes on the deliberately NON-evented paths: +// - An unresolved route (unknown webhook name), a visibility-perimeter/read-only +// denial (webhookRequestAllowed), a non-POST method, an operator-owned source +// (allowed_cidrs) or bearer (bearer_env) denial, and a rate-limit 429 are all +// cheap, unauthenticated, attacker-fully-controlled rejects that run at or +// before the limiter. Eventing them would be an event-log-flood amplification +// vector and a name-existence oracle (and would violate R2's "never confirm +// which hooks exist"), so the receiver rejects them silently — there is no +// reason string for them. The source/bearer gates run BEFORE the limiter so a +// disallowed caller cannot consume the shared per-hook delivery bucket that +// legitimate deliveries draw from; staying non-evented keeps that pre-limiter +// position from re-introducing the amplification. +// - operator_fault as an EVENT fires only for the POST-limiter verifier fault +// (verifier unavailable / verify error): the limiter throttles that path, so +// its per-request 503 event is bounded and diagnostically useful. The +// PRE-limiter access gates (allowed_cidrs, bearer_env) can also raise a 503 +// operator fault, but eventing those per request would re-introduce the flood +// amplifier, so they are non-evented and logged one-shot instead +// (rejectWebhookAccessOperatorFault); the 503 status is the caller-visible signal. // - no-match is classified as webhook.received (an accepted, authentic 2xx // delivery that no rule wanted), NOT as a rejection — so there is no // no_match reason. const ( - reasonMethodNotAllowed = "method_not_allowed" - reasonPerimeterDenied = "perimeter_denied" - reasonReadOnly = "read_only" - reasonRateLimited = "rate_limited" reasonBodyTooLarge = "body_too_large" reasonBadBody = "bad_body" reasonOperatorFault = "operator_fault" diff --git a/internal/config/webhook.go b/internal/config/webhook.go index a2a1fc72d9..5d30bada5f 100644 --- a/internal/config/webhook.go +++ b/internal/config/webhook.go @@ -1,9 +1,14 @@ package config import ( + "crypto/sha256" + "encoding/hex" "fmt" + "net/netip" "path/filepath" "regexp" + "sort" + "strconv" "strings" "github.com/gastownhall/gascity/internal/orders" @@ -28,14 +33,29 @@ var knownWebhookSchemes = map[string]bool{ "jwt-jwks": true, } -// hmacFamilyWebhookSchemes require a shared secret referenced via secret_env. -// discord-ed25519 (public key) and jwt-jwks (JWKS trust anchor) do not. -var hmacFamilyWebhookSchemes = map[string]bool{ +// secretEnvWebhookSchemes resolve secret material from an operator-owned env var +// via secret_env: the HMAC family (shared HMAC key) and discord-ed25519 (the app +// public key). jwt-jwks is the only scheme that carries no env secret (its trust +// anchor is the operator [webhooks].jwt_policy), so it is absent here. Mirrors +// the runtime SecretResolver's applicability so a missing/namespaced secret_env +// is caught at config load rather than only on first delivery. +var secretEnvWebhookSchemes = map[string]bool{ "github-hmac-sha256": true, "hmac-sha256": true, "slack-v0": true, + "discord-ed25519": true, } +// OperatorWebhookSecretEnvPrefix is the environment-variable namespace an +// operator controls for webhook secret material (HMAC keys, Discord public keys, +// and per-source bearer tokens). Because a pack authors [webhook.verify], +// requiring secret_env/bearer_env to live in this namespace prevents a pack from +// pointing secret resolution at an arbitrary ambient variable (HOME, GC_CITY, +// AWS_SECRET_ACCESS_KEY, …) — the load-bearing half of security review R1. It is +// the single source of truth for the prefix; webhookverify.OperatorSecretEnvPrefix +// references this constant so the load-time and runtime checks can never diverge. +const OperatorWebhookSecretEnvPrefix = "GC_WEBHOOK_" + // Webhook declares a city- or rig-scoped inbound HTTP receiver mounted under // /v0/city/{city}/hook/{name}. It mirrors the [[service]] declaration shape: // generic publication intent plus pack provenance, so the same edge routing @@ -47,6 +67,12 @@ type Webhook struct { // Scope selects city- or rig-scoped dispatch semantics, mirroring // Order.Scope. Empty defaults to city. Scope string `toml:"scope,omitempty" jsonschema:"enum=city,enum=rig"` + // Rig is the authoritative rig binding for a rig-scoped webhook (Scope=="rig"). + // It is REQUIRED when scope="rig" and forbidden otherwise: the receiver copies + // it into the dispatch scope so the sink constrains delivery to this rig (R4), + // and a rule that names any other rig is refused. Without it a rig-scoped + // webhook fails closed (it can target no rig). Leave unset for city scope. + Rig string `toml:"rig,omitempty"` // Publication declares generic publication intent, reusing the service // publication contract. Pack/fragment-contributed public webhooks are // capped to tenant unless the city grants them via [webhooks].allow_public. @@ -85,8 +111,17 @@ type WebhookVerify struct { SignatureHeader string `toml:"signature_header,omitempty"` // EventHeader names the request header carrying the provider event type. EventHeader string `toml:"event_header,omitempty"` - // DedupHeader names the request header carrying the delivery id used for - // at-least-once dedup. + // DedupHeader names the request header whose value is surfaced as the + // delivery id on webhook.received events for observability. It does NOT key + // at-least-once dedup for the signature-only schemes (github-hmac-sha256, + // hmac-sha256, slack-v0, discord-ed25519): those dedup on a hash of the + // signed body, because an unsigned or coarse header cannot safely key dedup — + // a captured valid delivery could be replayed under a fresh header id to + // re-fire the order. Only jwt-jwks keys dedup directly, on its signed + // per-delivery-unique "jti". As a consequence two deliveries with + // byte-identical signed bodies inside the dedup window collapse to one + // dispatch, so a source that must resend an identical payload has to carry a + // unique value inside the signed body. DedupHeader string `toml:"dedup_header,omitempty"` // TimestampHeader optionally names a request header carrying a signed // timestamp for replay defense. @@ -258,14 +293,13 @@ type WebhookAllowPublic struct { // Source is the pack/fragment provenance the grant is scoped to. Matched // against the webhook's stamped SourceDir. Source string `toml:"source"` - // Digest optionally pins the content digest of the granted webhook's - // security-relevant fields. - // - // TODO(R3): compute and enforce this digest over - // {visibility, verify scheme/secret_env/secret_key/trust-root, each rule's - // event/match/order/rig/target} so a content-swap upgrade auto-downgrades - // to tenant until the operator re-consents. E2 matches on {name, source} - // only; the digest field is reserved for that follow-up. + // Digest pins the content digest of the granted webhook's security-relevant + // fields (see WebhookContentDigest). It is REQUIRED for the grant to honor + // public exposure: applyWebhookPackGuard recomputes the digest at load and + // caps the webhook to tenant when the grant has no digest or the digest no + // longer matches (R3 content-scoped consent), so a content-swap upgrade of a + // public hook auto-downgrades until the operator re-consents to the new + // digest. The downgrade warning names the digest to pin. Digest string `toml:"digest,omitempty"` } @@ -309,49 +343,94 @@ func (r WebhookRule) TargetOrDefault() string { func ValidateWebhooks(webhooks []Webhook) error { seen := make(map[string]bool, len(webhooks)) for i, w := range webhooks { - if w.Name == "" { - return fmt.Errorf("webhook[%d]: name is required", i) - } - if !validWebhookName.MatchString(w.Name) { - return fmt.Errorf("webhook %q: name must match [a-zA-Z0-9][a-zA-Z0-9_-]*", w.Name) - } - if seen[w.Name] { - if w.SourceDir != "" { - return fmt.Errorf("webhook %q: duplicate name (from %q)", w.Name, w.SourceDir) - } - return fmt.Errorf("webhook %q: duplicate name", w.Name) + if err := validateWebhook(i, w, seen); err != nil { + return err } - seen[w.Name] = true + } + return nil +} - switch w.ScopeOrDefault() { - case "city", "rig": - default: - return fmt.Errorf("webhook %q: scope must be \"city\" or \"rig\", got %q", w.Name, w.Scope) - } +// validateWebhook validates one webhook declaration. The per-webhook checks are +// split into focused helpers so each function stays simple to read and reason +// about (low cognitive complexity) instead of one deeply-nested loop body. +func validateWebhook(i int, w Webhook, seen map[string]bool) error { + if err := validateWebhookIdentity(i, w, seen); err != nil { + return err + } + if err := validateWebhookScope(w); err != nil { + return err + } + if err := validateWebhookPublication(w); err != nil { + return err + } + if w.MaxPerMinute < 0 { + return fmt.Errorf("webhook %q: max_per_minute must be >= 0, got %d", w.Name, w.MaxPerMinute) + } + if err := validateWebhookVerify(w); err != nil { + return err + } + return validateWebhookRules(w) +} - switch strings.TrimSpace(strings.ToLower(w.Publication.Visibility)) { - case "", "private", "public", "tenant": - default: - return fmt.Errorf("webhook %q: publication.visibility must be \"private\", \"public\", or \"tenant\", got %q", w.Name, w.Publication.Visibility) +// validateWebhookIdentity checks the name shape and rejects duplicates. +func validateWebhookIdentity(i int, w Webhook, seen map[string]bool) error { + if w.Name == "" { + return fmt.Errorf("webhook[%d]: name is required", i) + } + if !validWebhookName.MatchString(w.Name) { + return fmt.Errorf("webhook %q: name must match [a-zA-Z0-9][a-zA-Z0-9_-]*", w.Name) + } + if seen[w.Name] { + if w.SourceDir != "" { + return fmt.Errorf("webhook %q: duplicate name (from %q)", w.Name, w.SourceDir) } - if hostname := strings.TrimSpace(strings.ToLower(w.Publication.Hostname)); hostname != "" && !validPublicationLabel.MatchString(hostname) { - return fmt.Errorf("webhook %q: publication.hostname must be a single DNS label, got %q", w.Name, w.Publication.Hostname) + return fmt.Errorf("webhook %q: duplicate name", w.Name) + } + seen[w.Name] = true + return nil +} + +// validateWebhookScope enforces the scope/rig pairing: a rig-scoped webhook MUST +// declare its authoritative rig binding (so the sink can constrain dispatch to +// that rig, R4), and a city-scoped webhook must not carry one. +func validateWebhookScope(w Webhook) error { + rig := strings.TrimSpace(w.Rig) + switch w.ScopeOrDefault() { + case "city": + if rig != "" { + return fmt.Errorf("webhook %q: rig is only valid for scope=\"rig\"", w.Name) } - if w.MaxPerMinute < 0 { - return fmt.Errorf("webhook %q: max_per_minute must be >= 0, got %d", w.Name, w.MaxPerMinute) + case "rig": + if rig == "" { + return fmt.Errorf("webhook %q: scope=\"rig\" requires a rig binding", w.Name) } + default: + return fmt.Errorf("webhook %q: scope must be \"city\" or \"rig\", got %q", w.Name, w.Scope) + } + return nil +} - if err := validateWebhookVerify(w); err != nil { - return err - } +// validateWebhookPublication checks the visibility enum and hostname label. +func validateWebhookPublication(w Webhook) error { + switch strings.TrimSpace(strings.ToLower(w.Publication.Visibility)) { + case "", "private", "public", "tenant": + default: + return fmt.Errorf("webhook %q: publication.visibility must be \"private\", \"public\", or \"tenant\", got %q", w.Name, w.Publication.Visibility) + } + if hostname := strings.TrimSpace(strings.ToLower(w.Publication.Hostname)); hostname != "" && !validPublicationLabel.MatchString(hostname) { + return fmt.Errorf("webhook %q: publication.hostname must be a single DNS label, got %q", w.Name, w.Publication.Hostname) + } + return nil +} - if len(w.Rules) == 0 { - return fmt.Errorf("webhook %q: at least one [[webhook.rule]] is required", w.Name) - } - for j, rule := range w.Rules { - if err := validateWebhookRule(w.Name, j, rule); err != nil { - return err - } +// validateWebhookRules requires at least one rule and validates each. +func validateWebhookRules(w Webhook) error { + if len(w.Rules) == 0 { + return fmt.Errorf("webhook %q: at least one [[webhook.rule]] is required", w.Name) + } + for j, rule := range w.Rules { + if err := validateWebhookRule(w.Name, j, rule); err != nil { + return err } } return nil @@ -365,18 +444,103 @@ func validateWebhookVerify(w Webhook) error { if !knownWebhookSchemes[scheme] { return fmt.Errorf("webhook %q: verify.scheme %q is not a known scheme (github-hmac-sha256, hmac-sha256, slack-v0, discord-ed25519, jwt-jwks)", w.Name, scheme) } - if env := strings.TrimSpace(w.Verify.SecretEnv); env != "" && !validWebhookSecretEnv.MatchString(env) { - return fmt.Errorf("webhook %q: verify.secret_env must be an environment variable name, got %q", w.Name, w.Verify.SecretEnv) + if err := validateWebhookSecretEnv(w.Name, scheme, w.Verify.SecretEnv); err != nil { + return err + } + if err := validateWebhookOperatorEnv(w.Name, "bearer_env", w.Verify.BearerEnv); err != nil { + return err + } + return validateWebhookAllowedCIDRs(w.Name, w.Verify.AllowedCIDRs) +} + +// validateWebhookSecretEnv enforces the R1 operator-namespace on secret_env at +// load time, mirroring the runtime webhookverify.SecretResolver so a +// missing/mis-namespaced secret fails at config load rather than only on first +// delivery: it is required for every scheme that resolves a secret +// (secretEnvWebhookSchemes), must be an env-var identifier, and must live in the +// GC_WEBHOOK_* operator namespace. +func validateWebhookSecretEnv(name, scheme, secretEnv string) error { + env := strings.TrimSpace(secretEnv) + if env == "" { + if secretEnvWebhookSchemes[scheme] { + return fmt.Errorf("webhook %q: verify.secret_env is required for scheme %q", name, scheme) + } + return nil } - if hmacFamilyWebhookSchemes[scheme] && strings.TrimSpace(w.Verify.SecretEnv) == "" { - return fmt.Errorf("webhook %q: verify.secret_env is required for scheme %q", w.Name, scheme) + return validateWebhookOperatorEnv(name, "secret_env", secretEnv) +} + +// validateWebhookOperatorEnv validates an optional operator-owned env reference +// (secret_env / bearer_env): when set it must be an env-var identifier inside the +// GC_WEBHOOK_* namespace so a pack cannot resolve an arbitrary ambient variable. +func validateWebhookOperatorEnv(name, field, value string) error { + env := strings.TrimSpace(value) + if env == "" { + return nil } - if env := strings.TrimSpace(w.Verify.BearerEnv); env != "" && !validWebhookSecretEnv.MatchString(env) { - return fmt.Errorf("webhook %q: verify.bearer_env must be an environment variable name, got %q", w.Name, w.Verify.BearerEnv) + if !validWebhookSecretEnv.MatchString(env) { + return fmt.Errorf("webhook %q: verify.%s must be an environment variable name, got %q", name, field, value) + } + if !strings.HasPrefix(env, OperatorWebhookSecretEnvPrefix) { + return fmt.Errorf("webhook %q: verify.%s %q must be in the operator namespace %q", name, field, env, OperatorWebhookSecretEnvPrefix) + } + return nil +} + +// validateWebhookAllowedCIDRs rejects malformed allowed_cidrs entries at load so +// an unparseable allowlist can never silently fail open at request time. +func validateWebhookAllowedCIDRs(name string, cidrs []string) error { + if _, err := ParseWebhookCIDRs(cidrs); err != nil { + return fmt.Errorf("webhook %q: %w", name, err) } return nil } +// ParseWebhookCIDRs parses an allowed_cidrs list into prefixes, accepting either +// CIDR notation ("192.30.252.0/22") or a bare address ("203.0.113.7", read as a +// host route). An IPv4-mapped IPv6 entry — bare ("::ffff:192.0.2.1") or CIDR-form +// ("::ffff:192.0.2.0/120") — is unmapped to its IPv4 form ("192.0.2.1", +// "192.0.2.0/24") so it matches the request IP the source check compares against +// — webhookRemoteIP unmaps the same way — rather than sitting as an IPv6 prefix +// that fail-closes (403) a legitimate IPv4 caller. It backs both load-time +// validation and the request-time source check so the two can never diverge. An +// empty or malformed entry is an error. +func ParseWebhookCIDRs(cidrs []string) ([]netip.Prefix, error) { + if len(cidrs) == 0 { + return nil, nil + } + out := make([]netip.Prefix, 0, len(cidrs)) + for _, c := range cidrs { + trimmed := strings.TrimSpace(c) + if trimmed == "" { + return nil, fmt.Errorf("verify.allowed_cidrs entry is empty") + } + if p, err := netip.ParsePrefix(trimmed); err == nil { + // Normalize an IPv4-mapped IPv6 CIDR to its equivalent IPv4 prefix so it + // matches the unmapped request peer webhookRemoteIP produces; left as an + // IPv6 range it would silently fail-close a legitimate IPv4 caller, the + // same divergence the bare-address Unmap below closes. A mapped prefix + // shorter than /96 spans beyond the mapped range and cannot be an IPv4 + // allowlist entry, so reject it loudly instead of letting it never match. + if p.Addr().Is4In6() { + if p.Bits() < 96 { + return nil, fmt.Errorf("verify.allowed_cidrs %q: IPv4-mapped prefix shorter than /96 is not a valid IPv4 range; use IPv4 CIDR notation", c) + } + p = netip.PrefixFrom(p.Addr().Unmap(), p.Bits()-96) + } + out = append(out, p.Masked()) + continue + } + addr, err := netip.ParseAddr(trimmed) + if err != nil { + return nil, fmt.Errorf("verify.allowed_cidrs %q is not a valid CIDR or IP", c) + } + addr = addr.Unmap() + out = append(out, netip.PrefixFrom(addr, addr.BitLen())) + } + return out, nil +} + func validateWebhookRule(webhookName string, idx int, rule WebhookRule) error { ctx := fmt.Sprintf("webhook %q: rule[%d]", webhookName, idx) if strings.TrimSpace(rule.Event) == "" { @@ -413,13 +577,19 @@ func validateWebhookRule(webhookName string, idx int, rule WebhookRule) error { // applyWebhookPackGuard enforces the default-closed pack-guard: a public // webhook contributed by a pack or fragment (non-empty SourceDir) is capped to -// tenant unless the root city.toml grants it via [webhooks].allow_public. +// tenant unless the root city.toml grants it via [webhooks].allow_public AND the +// grant's content digest matches the webhook's current security-relevant fields. // Root-authored webhooks (empty SourceDir) are operator-trusted and untouched. // // This is the load-bearing control the security review flagged (R3): it runs // once over the fully-composed webhook set — after every merge site has stamped // SourceDir — so provenance is centralized and cannot leak through an -// unstamped path. It returns the downgrade warnings for the caller to surface. +// unstamped path. Requiring a matching digest (not just name+source) closes the +// content-swap hole: an upgrade that changes the verifier, rules, order, or rig +// of a granted public hook no longer silently retains public exposure — it is +// auto-downgraded to tenant until the operator re-consents to the new digest. +// It returns the downgrade warnings (each carrying the digest to re-consent to) +// for the caller to surface. func applyWebhookPackGuard(cfg *City, cityRoot string) []string { if cfg == nil { return nil @@ -436,31 +606,123 @@ func applyWebhookPackGuard(cfg *City, cityRoot string) []string { if w.SourceDir == "" { continue } - if webhookPublicGranted(w.Name, w.SourceDir, cityRoot, cfg.WebhookPolicy.AllowPublic) { - continue + if reason := webhookPublicDenyReason(w, cityRoot, cfg.WebhookPolicy.AllowPublic); reason != "" { + w.Publication.Visibility = "tenant" + warnings = append(warnings, fmt.Sprintf( + "webhook %q: pack/fragment-contributed publication.visibility=\"public\" capped to \"tenant\" (%s)", + w.Name, reason)) } - w.Publication.Visibility = "tenant" - warnings = append(warnings, fmt.Sprintf( - "webhook %q: pack/fragment-contributed publication.visibility=\"public\" capped to \"tenant\" (no matching [webhooks].allow_public grant for source %q)", - w.Name, w.SourceDir)) } return warnings } -// webhookPublicGranted reports whether an operator-authored allow_public entry -// grants public exposure to the named webhook from the given provenance. A -// relative grant Source is resolved against cityRoot (the directory of the root -// city.toml). -func webhookPublicGranted(name, sourceDir, cityRoot string, grants []WebhookAllowPublic) bool { +// webhookPublicDenyReason returns "" when a pack/fragment public webhook is +// authorized to keep public exposure, or a human-readable reason to cap it to +// tenant. Authorization requires an operator-authored [webhooks].allow_public +// grant that matches the webhook by name+provenance AND pins a digest equal to +// the webhook's current content digest (R3 content-scoped consent). EVERY matching +// grant is considered, so a stale duplicate grant ordered ahead of a valid +// re-consent for the same name+source can never shadow it. A match with no digest, +// or only a stale digest, is not authorization — the reason names the current +// digest so the operator can re-consent by pinning it. +func webhookPublicDenyReason(w *Webhook, cityRoot string, grants []WebhookAllowPublic) string { + digest := WebhookContentDigest(*w) + matched := false // some grant matched name+provenance + sawPinnedDigest := false // a matching grant carried a (non-empty) digest for _, g := range grants { - if !strings.EqualFold(strings.TrimSpace(g.Name), strings.TrimSpace(name)) { + if !strings.EqualFold(strings.TrimSpace(g.Name), strings.TrimSpace(w.Name)) { + continue + } + if !webhookSourceMatches(w.SourceDir, g.Source, cityRoot) { + continue + } + matched = true + pinned := strings.TrimSpace(g.Digest) + if pinned == "" { continue } - if webhookSourceMatches(sourceDir, g.Source, cityRoot) { - return true + sawPinnedDigest = true + if strings.EqualFold(pinned, digest) { + return "" // a matching grant consents to the current content } } - return false + switch { + case !matched: + return fmt.Sprintf("no matching [webhooks].allow_public grant for source %q", w.SourceDir) + case !sawPinnedDigest: + return fmt.Sprintf("[webhooks].allow_public grant has no digest; pin digest=%q to consent to the current content", digest) + default: + return fmt.Sprintf("webhook content changed since consent; re-consent by setting [webhooks].allow_public digest=%q", digest) + } +} + +// WebhookContentDigest computes a stable digest over a webhook's +// security-relevant content for [webhooks].allow_public content-scoped consent +// (R3). It covers the fields whose change alters what the hook accepts, how it +// authenticates, and what it dispatches — scope/rig, every verify field, and each +// rule's event/match/order/rig/target/args — so a content-swap upgrade produces a +// different digest and auto-downgrades a granted public hook to tenant until the +// operator re-consents. It deliberately EXCLUDES name (already the grant key), +// SourceDir (provenance, matched separately), publication.visibility (always +// "public" at the guard check, so it carries no information), and MaxPerMinute +// (a downward-only self-limit that cannot widen exposure). Values are Go-quoted +// so no field value can forge the field separators. +func WebhookContentDigest(w Webhook) string { + var b strings.Builder + kv := func(k, v string) { + b.WriteString(k) + b.WriteByte('=') + b.WriteString(strconv.Quote(v)) + b.WriteByte('\n') + } + kv("scope", w.ScopeOrDefault()) + kv("rig", strings.TrimSpace(w.Rig)) + kv("publication.hostname", strings.TrimSpace(strings.ToLower(w.Publication.Hostname))) + v := w.Verify + kv("verify.scheme", strings.TrimSpace(v.Scheme)) + kv("verify.secret_env", strings.TrimSpace(v.SecretEnv)) + kv("verify.secret_key", strings.TrimSpace(v.SecretKey)) + kv("verify.signature_header", v.SignatureHeader) + kv("verify.event_header", v.EventHeader) + kv("verify.dedup_header", v.DedupHeader) + kv("verify.timestamp_header", v.TimestampHeader) + kv("verify.replay_window", v.ReplayWindow) + kv("verify.issuer", v.Issuer) + kv("verify.jwks_url", v.JWKSURL) + kv("verify.audience", v.Audience) + kv("verify.bearer_env", strings.TrimSpace(v.BearerEnv)) + cidrs := append([]string(nil), v.AllowedCIDRs...) + sort.Strings(cidrs) + kv("verify.allowed_cidrs", strings.Join(cidrs, ",")) + for i, r := range w.Rules { + p := "rule[" + strconv.Itoa(i) + "]." + kv(p+"event", strings.TrimSpace(r.Event)) + kv(p+"order", strings.TrimSpace(r.Order)) + kv(p+"rig", strings.TrimSpace(r.Rig)) + kv(p+"target", r.TargetOrDefault()) + kv(p+"match", canonicalStringMap(r.Match)) + kv(p+"args", canonicalStringMap(r.Args)) + } + sum := sha256.Sum256([]byte(b.String())) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// canonicalStringMap renders a string map to a stable, injection-safe string: +// entries sorted by key, each key and value Go-quoted, joined with commas. +func canonicalStringMap(m map[string]string) string { + if len(m) == 0 { + return "" + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, strconv.Quote(k)+":"+strconv.Quote(m[k])) + } + return strings.Join(parts, ",") } // webhookSourceMatches reports whether a stamped provenance directory satisfies diff --git a/internal/config/webhook_test.go b/internal/config/webhook_test.go index 722d102a28..b0f3396b31 100644 --- a/internal/config/webhook_test.go +++ b/internal/config/webhook_test.go @@ -1,6 +1,8 @@ package config import ( + "fmt" + "net/netip" "path/filepath" "strings" "testing" @@ -8,6 +10,29 @@ import ( "github.com/gastownhall/gascity/internal/fsys" ) +// ghPublicPackTOML is a pack that contributes a public github webhook, used by +// the allow_public content-digest tests. +const ghPublicPackTOML = ` +[pack] +name = "gh" +schema = 1 + +[[webhook]] +name = "github" + +[webhook.publication] +visibility = "public" +hostname = "hooks" + +[webhook.verify] +scheme = "github-hmac-sha256" +secret_env = "GC_WEBHOOK_GITHUB_SECRET" + +[[webhook.rule]] +event = "pull_request" +order = "pr-review-request" +` + // (a) A full [[webhook]] with every sub-table parses and validates. func TestWebhook_ParsesAllSubTables(t *testing.T) { dir := t.TempDir() @@ -193,9 +218,10 @@ order = "backlog-patrol" } } -// (d) A city-level allow_public grant honors public exposure for the matching -// pack webhook. -func TestWebhook_AllowPublicGrantHonorsPublic(t *testing.T) { +// (d) A city-level allow_public grant with NO digest is default-closed: the pack +// webhook is capped to tenant even though name+source match, because a name-only +// grant would silently re-honor a content swap (R3 content-scoped consent). +func TestWebhook_AllowPublicWithoutDigestCapped(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "city.toml", ` [workspace] @@ -206,26 +232,7 @@ includes = ["packs/gh"] name = "github" source = "packs/gh" `) - writeFile(t, dir, "packs/gh/pack.toml", ` -[pack] -name = "gh" -schema = 1 - -[[webhook]] -name = "github" - -[webhook.publication] -visibility = "public" -hostname = "hooks" - -[webhook.verify] -scheme = "github-hmac-sha256" -secret_env = "GC_WEBHOOK_GITHUB_SECRET" - -[[webhook.rule]] -event = "pull_request" -order = "pr-review-request" -`) + writeFile(t, dir, "packs/gh/pack.toml", ghPublicPackTOML) cfg, _, err := LoadWithIncludes(fsys.OSFS{}, filepath.Join(dir, "city.toml")) if err != nil { @@ -234,12 +241,92 @@ order = "pr-review-request" if len(cfg.Webhooks) != 1 { t.Fatalf("want 1 webhook, got %d", len(cfg.Webhooks)) } - w := cfg.Webhooks[0] - if w.SourceDir == "" { + if w := cfg.Webhooks[0]; w.SourceDir == "" { t.Fatal("imported-pack webhook must carry SourceDir provenance") } - if w.Publication.Visibility != "public" { - t.Errorf("visibility = %q, want public (granted by [webhooks].allow_public)", w.Publication.Visibility) + if got := cfg.Webhooks[0].Publication.Visibility; got != "tenant" { + t.Errorf("visibility = %q, want tenant (a name+source grant with no digest must not honor public)", got) + } +} + +// (d') A grant whose digest matches the webhook's current content honors public +// exposure; a stale/placeholder digest does not (content-scoped consent, R3). +func TestWebhook_AllowPublicWithMatchingDigestHonored(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "packs/gh/pack.toml", ghPublicPackTOML) + grantWith := func(digest string) string { + return fmt.Sprintf(` +[workspace] +name = "test" +includes = ["packs/gh"] + +[[webhooks.allow_public]] +name = "github" +source = "packs/gh" +digest = %q +`, digest) + } + + // A placeholder (stale) digest is still capped to tenant; the composed webhook + // then yields the real digest (visibility is excluded from the digest, so the + // capped value is fine to compute from). + writeFile(t, dir, "city.toml", grantWith("sha256:stale")) + cfg, _, err := LoadWithIncludes(fsys.OSFS{}, filepath.Join(dir, "city.toml")) + if err != nil { + t.Fatalf("LoadWithIncludes (stale): %v", err) + } + if got := cfg.Webhooks[0].Publication.Visibility; got != "tenant" { + t.Fatalf("stale-digest grant: visibility = %q, want tenant", got) + } + digest := WebhookContentDigest(cfg.Webhooks[0]) + + // Re-consent with the correct digest → public honored. + writeFile(t, dir, "city.toml", grantWith(digest)) + cfg2, _, err := LoadWithIncludes(fsys.OSFS{}, filepath.Join(dir, "city.toml")) + if err != nil { + t.Fatalf("LoadWithIncludes (matching): %v", err) + } + if got := cfg2.Webhooks[0].Publication.Visibility; got != "public" { + t.Errorf("matching-digest grant: visibility = %q, want public", got) + } +} + +// (d”) Duplicate allow_public grants for the same name+source must not let a +// stale-digest entry shadow a later valid re-consent: authorization holds when +// ANY matching grant pins the current digest, regardless of grant order. +func TestWebhookPublicDenyReason_StaleGrantDoesNotShadowValid(t *testing.T) { + const cityRoot = "/city" + w := &Webhook{ + Name: "github", + SourceDir: "/city/packs/gh", + Verify: WebhookVerify{Scheme: "github-hmac-sha256", SecretEnv: "GC_WEBHOOK_GITHUB_SECRET"}, + Rules: []WebhookRule{{Event: "pull_request", Order: "pr-review-request"}}, + } + digest := WebhookContentDigest(*w) + stale := WebhookAllowPublic{Name: "github", Source: "/city/packs/gh", Digest: "sha256:stale"} + valid := WebhookAllowPublic{Name: "github", Source: "/city/packs/gh", Digest: digest} + + // Stale grant FIRST, valid re-consent SECOND → authorized (the shadowing bug: + // the stale first match used to cap the hook despite the later valid grant). + if reason := webhookPublicDenyReason(w, cityRoot, []WebhookAllowPublic{stale, valid}); reason != "" { + t.Errorf("stale-then-valid: got deny reason %q, want authorized (empty)", reason) + } + // Order-independent: valid FIRST, stale SECOND → still authorized. + if reason := webhookPublicDenyReason(w, cityRoot, []WebhookAllowPublic{valid, stale}); reason != "" { + t.Errorf("valid-then-stale: got deny reason %q, want authorized (empty)", reason) + } + // Only stale duplicates (none pin the current digest) → capped, and the reason + // is the content-changed re-consent prompt (not the no-digest or no-match one). + onlyStale := []WebhookAllowPublic{ + {Name: "github", Source: "/city/packs/gh", Digest: "sha256:stale-a"}, + {Name: "github", Source: "/city/packs/gh", Digest: "sha256:stale-b"}, + } + reason := webhookPublicDenyReason(w, cityRoot, onlyStale) + if reason == "" { + t.Fatal("only-stale duplicates: got authorized, want a content-changed deny reason") + } + if !strings.Contains(reason, "content changed") { + t.Errorf("only-stale duplicates: reason = %q, want it to mention content change", reason) } } @@ -385,6 +472,30 @@ func TestValidateWebhooks_Rejects(t *testing.T) { w.Rules = []WebhookRule{{Event: "e", Order: "o", Args: map[string]string{"GC_CITY": "{{action}}"}}} return w }(), "reserved controller-owned env key"}, + {"secret_env outside operator namespace", func() Webhook { + w := base(Webhook{Name: "h"}) + w.Verify.SecretEnv = "MY_SECRET" + return w + }(), "operator namespace"}, + {"discord requires secret_env", func() Webhook { + return Webhook{Name: "h", Verify: WebhookVerify{Scheme: "discord-ed25519"}, Rules: []WebhookRule{{Event: "e", Order: "o"}}} + }(), "secret_env is required"}, + {"bearer_env outside operator namespace", func() Webhook { + w := base(Webhook{Name: "h"}) + w.Verify.BearerEnv = "SOME_TOKEN" + return w + }(), "operator namespace"}, + {"malformed allowed_cidr", func() Webhook { + w := base(Webhook{Name: "h"}) + w.Verify.AllowedCIDRs = []string{"not-a-cidr"} + return w + }(), "allowed_cidrs"}, + {"rig scope without rig", func() Webhook { + return base(Webhook{Name: "h", Scope: "rig"}) + }(), "requires a rig binding"}, + {"city scope with rig", func() Webhook { + return base(Webhook{Name: "h", Rig: "maintainer"}) + }(), "rig is only valid for scope"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -399,6 +510,132 @@ func TestValidateWebhooks_Rejects(t *testing.T) { } } +// A rig-scoped webhook with its authoritative rig binding validates; the sink +// uses that rig to constrain dispatch (R4). +func TestValidateWebhooks_RigScopedValid(t *testing.T) { + w := Webhook{ + Name: "maintainer-hook", + Scope: "rig", + Rig: "maintainer", + Verify: WebhookVerify{Scheme: "hmac-sha256", SecretEnv: "GC_WEBHOOK_MAINT"}, + Rules: []WebhookRule{{Event: "issue", Order: "triage", Rig: "maintainer"}}, + } + if err := ValidateWebhooks([]Webhook{w}); err != nil { + t.Fatalf("valid rig-scoped webhook rejected: %v", err) + } +} + +// Valid operator-namespaced bearer_env and well-formed allowed_cidrs (CIDR and +// bare-IP forms) are accepted. +func TestValidateWebhooks_BearerAndCIDRAccepted(t *testing.T) { + w := Webhook{ + Name: "gh", + Verify: WebhookVerify{ + Scheme: "github-hmac-sha256", SecretEnv: "GC_WEBHOOK_GH", + BearerEnv: "GC_WEBHOOK_GH_BEARER", + AllowedCIDRs: []string{"192.30.252.0/22", "203.0.113.7"}, + }, + Rules: []WebhookRule{{Event: "push", Order: "build", Args: map[string]string{"ref": "{{ref}}"}}}, + } + if err := ValidateWebhooks([]Webhook{w}); err != nil { + t.Fatalf("valid bearer_env/allowed_cidrs rejected: %v", err) + } +} + +// A bare IPv4-mapped IPv6 allowlist entry is unmapped to its IPv4 form so it +// matches the unmapped request IP the source check compares against. Without the +// unmap it would parse as an IPv6 /128 and fail-close (403) a legitimate IPv4 +// caller, diverging from the request-time normalization the parser promises to +// share. +func TestParseWebhookCIDRs_UnmapsBareIPv4Mapped(t *testing.T) { + prefixes, err := ParseWebhookCIDRs([]string{"::ffff:192.0.2.1"}) + if err != nil { + t.Fatalf("ParseWebhookCIDRs(::ffff:192.0.2.1) error: %v", err) + } + if len(prefixes) != 1 { + t.Fatalf("got %d prefixes, want 1", len(prefixes)) + } + p := prefixes[0] + if !p.Addr().Is4() { + t.Errorf("parsed prefix addr = %v, want unmapped IPv4 form", p.Addr()) + } + // webhookRemoteIP unmaps 4-in-6 request IPs, so a request from 192.0.2.1 must + // fall inside the parsed mapped-form entry. + if req := netip.MustParseAddr("192.0.2.1"); !p.Contains(req) { + t.Errorf("prefix %v does not contain unmapped request IP %v", p, req) + } +} + +// A CIDR-form IPv4-mapped IPv6 allowlist entry ("::ffff:192.0.2.0/120") is +// normalized to its equivalent IPv4 prefix ("192.0.2.0/24"), matching the +// unmapped request IP the source check compares against. Before the fix the +// ParsePrefix branch appended the prefix without unmapping, so it stayed an IPv6 +// prefix that fail-closed (403) a legitimate IPv4 caller — the same divergence +// the bare-address case above closes, but for CIDR notation. +func TestParseWebhookCIDRs_UnmapsMappedCIDRPrefix(t *testing.T) { + prefixes, err := ParseWebhookCIDRs([]string{"::ffff:192.0.2.0/120"}) + if err != nil { + t.Fatalf("ParseWebhookCIDRs(::ffff:192.0.2.0/120) error: %v", err) + } + if len(prefixes) != 1 { + t.Fatalf("got %d prefixes, want 1", len(prefixes)) + } + p := prefixes[0] + if !p.Addr().Is4() { + t.Errorf("parsed prefix addr = %v, want unmapped IPv4 form", p.Addr()) + } + if p.Bits() != 24 { + t.Errorf("parsed prefix bits = %d, want 24 (a /120 mapped prefix is a /24 IPv4 range)", p.Bits()) + } + // A request from inside the range matches; one just outside it does not — proving + // the prefix width survived the conversion rather than collapsing to a host route. + if in := netip.MustParseAddr("192.0.2.200"); !p.Contains(in) { + t.Errorf("prefix %v does not contain in-range IPv4 %v", p, in) + } + if out := netip.MustParseAddr("192.0.3.1"); p.Contains(out) { + t.Errorf("prefix %v wrongly contains out-of-range IPv4 %v", p, out) + } +} + +// A mapped-form prefix shorter than /96 spans beyond the IPv4-mapped range, so it +// cannot represent an IPv4 allowlist entry and is rejected at parse (and thus at +// config load) rather than silently never matching an unmapped IPv4 peer. +func TestParseWebhookCIDRs_RejectsSub96MappedPrefix(t *testing.T) { + if _, err := ParseWebhookCIDRs([]string{"::ffff:0.0.0.0/64"}); err == nil { + t.Fatal("ParseWebhookCIDRs(::ffff:0.0.0.0/64) = nil error, want rejection of a sub-/96 mapped prefix") + } +} + +// WebhookContentDigest is stable across equivalent content and changes when a +// security-relevant field (here the target order) changes, but ignores the +// excluded fields (name, SourceDir, visibility, max_per_minute). +func TestWebhookContentDigest_StableAndSensitive(t *testing.T) { + w := Webhook{ + Name: "github", + Publication: ServicePublicationConfig{Visibility: "public"}, + Verify: WebhookVerify{Scheme: "github-hmac-sha256", SecretEnv: "GC_WEBHOOK_GH"}, + Rules: []WebhookRule{{Event: "pull_request", Order: "pr-review", Args: map[string]string{"repo": "{{repo}}"}}}, + } + base := WebhookContentDigest(w) + + // Excluded fields do not change the digest. + ignored := w + ignored.Name = "renamed" + ignored.SourceDir = "/packs/elsewhere" + ignored.Publication.Visibility = "tenant" + ignored.MaxPerMinute = 99 + if got := WebhookContentDigest(ignored); got != base { + t.Errorf("digest changed on an excluded-field edit: %q != %q", got, base) + } + + // A security-relevant change (target order) changes the digest. + swapped := w + swapped.Rules = []WebhookRule{{Event: "pull_request", Order: "attacker-order", Args: map[string]string{"repo": "{{repo}}"}}} + if got := WebhookContentDigest(swapped); got == base { + t.Error("digest must change when a rule's target order changes (content-swap detection)") + } +} + func TestValidateWebhooks_ConversationRuleNeedsNoOrder(t *testing.T) { w := Webhook{ Name: "slack", diff --git a/internal/orders/order.go b/internal/orders/order.go index 77b185f950..706929adf3 100644 --- a/internal/orders/order.go +++ b/internal/orders/order.go @@ -273,13 +273,20 @@ func Validate(a Order) error { // MissingRequiredParams returns the names of declared-required params that are // absent from vars, sorted. It returns nil when every required param is present. +// +// A required param is "missing" when its key is absent OR its value is empty: +// webhook arg extraction renders a template whose payload path does not resolve +// to the empty string and still inserts the key, so a presence-only check would +// fire an order with an empty required value. Treating empty-as-absent makes +// `required = true` mean required-and-non-empty for both webhook dispatch and +// `gc order run --var key=` (an explicitly-empty value is not a supplied value). func (a *Order) MissingRequiredParams(vars map[string]string) []string { var missing []string for name, p := range a.Params { if !p.Required { continue } - if _, ok := vars[name]; !ok { + if strings.TrimSpace(vars[name]) == "" { missing = append(missing, name) } } diff --git a/internal/orders/order_test.go b/internal/orders/order_test.go index a02d90eac7..5e08b0dcc9 100644 --- a/internal/orders/order_test.go +++ b/internal/orders/order_test.go @@ -544,8 +544,20 @@ func TestValidateRequiredParams(t *testing.T) { t.Fatalf("error = %q, want it to name missing param pr", err.Error()) } - // A present-but-empty value still counts as supplied. - if err := ValidateRequiredParams(a, map[string]string{"repo": "octo/demo", "pr": ""}); err != nil { - t.Fatalf("ValidateRequiredParams with empty-but-present pr = %v, want nil", err) + // A present-but-empty value counts as MISSING: webhook arg extraction inserts + // the key even when the payload path resolved to "", so a required param that + // rendered empty must not be treated as supplied (else the order fires with an + // empty required value). + emptyErr := ValidateRequiredParams(a, map[string]string{"repo": "octo/demo", "pr": ""}) + if emptyErr == nil { + t.Fatal("ValidateRequiredParams with empty-but-present pr = nil, want error (empty required value is not supplied)") + } + if !strings.Contains(emptyErr.Error(), "pr") { + t.Fatalf("error = %q, want it to name the empty required param pr", emptyErr.Error()) + } + + // A whitespace-only value is likewise treated as missing. + if err := ValidateRequiredParams(a, map[string]string{"repo": "octo/demo", "pr": " "}); err == nil { + t.Fatal("ValidateRequiredParams with whitespace-only pr = nil, want error") } } diff --git a/internal/webhooksink/sink.go b/internal/webhooksink/sink.go index 3a37f4b70c..e75cb42cc3 100644 --- a/internal/webhooksink/sink.go +++ b/internal/webhooksink/sink.go @@ -19,9 +19,12 @@ // 1. the rule's {order, rig} is within the receiving webhook's provenance scope // (R4): a rig-scoped webhook may target only its own rig; a city-scoped // webhook may target the city or any rig; -// 2. the resolved order opts in with trigger="webhook" — a webhook may never +// 2. a public webhook may not fire an exec (sh -c) order (R4) — public +// deliveries are limited to formula orders so the pack-verified public +// ingress can never reach the in-process shell-exec sink; +// 3. the resolved order opts in with trigger="webhook" — a webhook may never // fire an order that did not declare itself webhook-triggered; -// 3. every declared-required param is present in the extracted args (E1). +// 4. every declared-required param is present in the extracted args (E1). // // # R4 — arg namespacing // @@ -60,12 +63,23 @@ type WebhookScope struct { Scope string // Rig is the webhook's own rig when Scope=="rig" (empty for city scope). Rig string + // Visibility is the webhook's EFFECTIVE (post pack-guard) publication + // visibility: "public", "tenant", or "private". A public webhook's only gate + // is its pack-authored signature verify, so the sink refuses to let it reach + // the exec (sh -c) sink (R4); private/tenant hooks are additionally gated by + // the receiver's internal-origin perimeter and may target exec orders. + Visibility string // SourceDir is the pack/fragment provenance ("" ⇒ operator-authored root). // Carried for future content-scoped consent (R3); not consulted by v0 rig // scoping, which keys on Scope/Rig. SourceDir string } +// IsPublic reports whether the webhook's effective visibility is public. +func (s WebhookScope) IsPublic() bool { + return strings.EqualFold(strings.TrimSpace(s.Visibility), "public") +} + // ConversationSink routes a verified conversation-target delivery into the // realtime chat path (Slack/Discord → extmsg). It is defined here so the order // sink and the receiver depend on a stable seam; the working implementation is @@ -130,14 +144,26 @@ func routeOrder(ctx context.Context, deps Deps, scope WebhookScope, match webhoo return res, nil } - // (2) A webhook may fire only orders that explicitly opt in. + // (2) A public webhook may never fire an exec (sh -c) order. A public hook's + // only gate is its pack-authored signature verify (R1), so reaching the + // in-process shell-exec sink would preserve the red-team RCE path the design + // set out to remove; public deliveries are forced through formula orders only. + // Private/tenant hooks are additionally gated by the receiver's internal-origin + // perimeter, so they may still target exec orders. + if scope.IsPublic() && a.IsExec() { + res.Rejected = true + res.Reason = fmt.Sprintf("public webhook %q may not fire exec order %q; public deliveries are limited to formula orders", scope.Name, a.ScopedName()) + return res, nil + } + + // (3) A webhook may fire only orders that explicitly opt in. if strings.TrimSpace(a.Trigger) != "webhook" { res.Rejected = true res.Reason = fmt.Sprintf("order %q has trigger %q; a webhook may only fire trigger=\"webhook\" orders", a.ScopedName(), a.Trigger) return res, nil } - // (3) Required-param validation against the RAW extracted args (keyed by the + // (4) Required-param validation against the RAW extracted args (keyed by the // declared param name), before any namespacing. if err := orders.ValidateRequiredParams(a, match.Vars); err != nil { res.Rejected = true diff --git a/internal/webhooksink/sink_test.go b/internal/webhooksink/sink_test.go index b548d1114f..972b65f1e6 100644 --- a/internal/webhooksink/sink_test.go +++ b/internal/webhooksink/sink_test.go @@ -108,6 +108,48 @@ func TestRouteOrderRefusesNonWebhookTrigger(t *testing.T) { } } +// A public webhook may not fire an exec (sh -c) order — the RCE sink the design +// removed from public ingress. Public deliveries are limited to formula orders. +func TestRouteOrderRefusesPublicExecOrder(t *testing.T) { + order := orders.Order{Name: "deploy-script", Trigger: "webhook", Exec: "deploy.sh", Params: map[string]orders.OrderParam{"ref": {}}} + disp := &fakeDispatcher{ret: orderdispatch.DispatchResult{Fired: true}} + deps := Deps{Dispatcher: disp, ResolveOrder: resolverFor(order)} + + res, err := Route(context.Background(), deps, + WebhookScope{Name: "github", Scope: "city", Visibility: "public"}, + webhookmatch.MatchResult{Target: "order", Order: "deploy-script"}) + if err != nil { + t.Fatalf("Route: %v", err) + } + if !res.Rejected || res.Dispatched { + t.Fatalf("expected refusal, got %+v", res) + } + if disp.calls != 0 { + t.Fatalf("dispatcher called %d times; a public webhook must never fire an exec order", disp.calls) + } + if !strings.Contains(res.Reason, "exec") || !strings.Contains(res.Reason, "formula") { + t.Fatalf("reason = %q, want it to explain the public-hook exec restriction", res.Reason) + } +} + +// A NON-public (tenant/private) webhook may still fire an exec order: it is gated +// by the receiver's internal-origin perimeter, so the exec sink stays available. +func TestRouteOrderAllowsTenantExecOrder(t *testing.T) { + order := orders.Order{Name: "deploy-script", Trigger: "webhook", Exec: "deploy.sh"} + disp := &fakeDispatcher{ret: orderdispatch.DispatchResult{Fired: true}} + deps := Deps{Dispatcher: disp, ResolveOrder: resolverFor(order)} + + res, err := Route(context.Background(), deps, + WebhookScope{Name: "plane", Scope: "city", Visibility: "tenant"}, + webhookmatch.MatchResult{Target: "order", Order: "deploy-script"}) + if err != nil { + t.Fatalf("Route: %v", err) + } + if res.Rejected || !res.Dispatched { + t.Fatalf("a tenant webhook must be allowed to fire an exec order, got %+v", res) + } +} + // (c) A rig-scoped webhook targeting a foreign rig is refused before resolution. func TestRouteOrderRefusesForeignRig(t *testing.T) { // Resolver would happily return an order for the foreign rig; the scope guard diff --git a/internal/webhookverify/discord.go b/internal/webhookverify/discord.go index 891efed182..d3874c2cb8 100644 --- a/internal/webhookverify/discord.go +++ b/internal/webhookverify/discord.go @@ -4,6 +4,7 @@ import ( "context" "crypto/ed25519" "encoding/hex" + "encoding/json" "errors" "fmt" "strconv" @@ -85,12 +86,8 @@ func (v *discordEd25519) Verify(_ context.Context, req VerifyRequest) (VerifyRes if err != nil { return failf("%s is not a unix timestamp", v.timestampHeader), nil } - skew := effectiveNow(req, v.now).Sub(time.Unix(tsSecs, 0)) - if skew < 0 { - skew = -skew - } - if skew > v.window { - return failf("%s skew %s exceeds replay window %s", v.timestampHeader, skew.Truncate(time.Second), v.window), nil + if !withinReplayWindow(effectiveNow(req, v.now), tsSecs, v.window) { + return failf("%s %d is outside the %s replay window", v.timestampHeader, tsSecs, v.window), nil } msg := make([]byte, 0, len(ts)+len(req.Body)) msg = append(msg, ts...) @@ -98,13 +95,46 @@ func (v *discordEd25519) Verify(_ context.Context, req VerifyRequest) (VerifyRes if !ed25519.Verify(pub, msg, sig) { return failf("%s does not match", v.signatureHeader), nil } - res := VerifyResult{OK: true} + res := VerifyResult{OK: true, EventType: discordEventType(req.Body)} if v.dedupHeader != "" { res.DedupID = strings.TrimSpace(req.Header.Get(v.dedupHeader)) } return res, nil } +// discordEventType derives the rule-facing event type from a verified Discord +// interaction body: its interaction "type", mapped to a stable lowercase name +// so a rule can select a non-PING interaction (e.g. `event = +// "application_command"`) and narrow further with a match on `data.name`. +// Unknown/future types fall back to "interaction_" so the value is always +// non-empty and legible; a body that is not the expected JSON object yields "". +// (Type 1 PING is short-circuited to PONG by the receiver before matching, so +// "ping" here is only ever surfaced for observability.) +func discordEventType(body []byte) string { + var p struct { + Type json.Number `json:"type"` + } + if err := json.Unmarshal(body, &p); err != nil { + return "" + } + switch n := p.Type.String(); n { + case "": + return "" + case "1": + return "ping" + case "2": + return "application_command" + case "3": + return "message_component" + case "4": + return "application_command_autocomplete" + case "5": + return "modal_submit" + default: + return "interaction_" + n + } +} + // decodeEd25519PublicKey interprets operator-provided public-key material as // either hex (Discord's portal form, 64 hex chars) or raw 32 bytes. A malformed // key is an operator fault, so it returns an error rather than a failed result. diff --git a/internal/webhookverify/discord_test.go b/internal/webhookverify/discord_test.go index f6e248d527..e1ea949f0d 100644 --- a/internal/webhookverify/discord_test.go +++ b/internal/webhookverify/discord_test.go @@ -4,6 +4,8 @@ import ( "context" "crypto/ed25519" "encoding/hex" + "fmt" + "math" "testing" "time" @@ -195,6 +197,56 @@ func TestDiscordEd25519_ReplayWindowClampedToMax(t *testing.T) { } } +// The Discord event type is derived from the verified interaction body's type so +// a rule can select a non-PING interaction (e.g. application_command). +func TestDiscordEd25519_EventTypeFromBody(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + ts := "1700000200" + cases := map[string]string{ + `{"type":2,"data":{"name":"fix"}}`: "application_command", + `{"type":3}`: "message_component", + `{"type":5}`: "modal_submit", + `{"type":1}`: "ping", + } + v, _ := New("discord-ed25519", config.WebhookVerify{}, Options{}) + for body, want := range cases { + res, err := v.Verify(context.Background(), VerifyRequest{ + Body: []byte(body), Secret: pub, + Header: hdr(discordSignatureHeader, discordSig(priv, ts, []byte(body)), discordTimestampHeader, ts), + Now: discordClockAt(1_700_000_200), + }) + if err != nil { + t.Fatalf("Verify(%s): %v", body, err) + } + if !res.OK { + t.Fatalf("Verify(%s) not OK: %q", body, res.Reason) + } + if res.EventType != want { + t.Errorf("body %s EventType = %q, want %q", body, res.EventType, want) + } + } +} + +// Regression: a far-future signed timestamp must be rejected (the clamp-underflow +// path that a naive abs(skew) > window check would silently pass). +func TestDiscordEd25519_FarFutureTimestampRejected(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + ts := fmt.Sprintf("%d", int64(math.MaxInt64)) + body := []byte(`{"type":2}`) + v, _ := New("discord-ed25519", config.WebhookVerify{}, Options{}) + res, err := v.Verify(context.Background(), VerifyRequest{ + Body: body, Secret: pub, + Header: hdr(discordSignatureHeader, discordSig(priv, ts, body), discordTimestampHeader, ts), + Now: discordClockAt(1_700_000_000), + }) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if res.OK { + t.Fatal("a far-future signed timestamp must be rejected") + } +} + func hexVal(b byte) int { switch { case b >= '0' && b <= '9': diff --git a/internal/webhookverify/secret.go b/internal/webhookverify/secret.go index 081eae190d..0a04ab2479 100644 --- a/internal/webhookverify/secret.go +++ b/internal/webhookverify/secret.go @@ -14,8 +14,10 @@ import ( // controls for webhook secrets. A WebhookVerify.SecretEnv must start with this // prefix so a pack cannot point secret resolution at an arbitrary ambient // variable (HOME, AWS_SECRET_ACCESS_KEY, GC_CITY, …). This is the load-bearing -// half of security review R1. -const OperatorSecretEnvPrefix = "GC_WEBHOOK_" +// half of security review R1. It aliases config.OperatorWebhookSecretEnvPrefix +// so the runtime resolver here and config's load-time validation share one +// source of truth and can never drift apart. +const OperatorSecretEnvPrefix = config.OperatorWebhookSecretEnvPrefix // MinSecretBytes is the minimum accepted secret length. A shorter (or empty) // secret is rejected so a misconfigured or unset secret fails closed instead of diff --git a/internal/webhookverify/slack.go b/internal/webhookverify/slack.go index bbca4ca082..e72121072d 100644 --- a/internal/webhookverify/slack.go +++ b/internal/webhookverify/slack.go @@ -4,6 +4,7 @@ import ( "context" "crypto/subtle" "encoding/hex" + "encoding/json" "errors" "strconv" "strings" @@ -61,12 +62,8 @@ func (v *slackV0) Verify(_ context.Context, req VerifyRequest) (VerifyResult, er if err != nil { return failf("%s is not a unix timestamp", v.timestampHeader), nil } - skew := effectiveNow(req, v.now).Sub(time.Unix(tsSecs, 0)) - if skew < 0 { - skew = -skew - } - if skew > v.window { - return failf("%s skew %s exceeds replay window %s", v.timestampHeader, skew.Truncate(time.Second), v.window), nil + if !withinReplayWindow(effectiveNow(req, v.now), tsSecs, v.window) { + return failf("%s %d is outside the %s replay window", v.timestampHeader, tsSecs, v.window), nil } sig := strings.TrimSpace(req.Header.Get(v.signatureHeader)) @@ -91,5 +88,28 @@ func (v *slackV0) Verify(_ context.Context, req VerifyRequest) (VerifyResult, er if subtle.ConstantTimeCompare(provided, expected) != 1 { return failf("%s does not match", v.signatureHeader), nil } - return VerifyResult{OK: true, DedupID: tsRaw}, nil + return VerifyResult{OK: true, DedupID: tsRaw, EventType: slackEventType(req.Body)}, nil +} + +// slackEventType derives the rule-facing event type from a verified Slack +// payload: the nested "event.type" for an Events API event_callback (so a rule +// can select `event = "message"`), falling back to the top-level "type" for +// envelopes that carry no nested event (e.g. "url_verification"). It returns "" +// when the body is not the expected JSON object; the matcher then only matches a +// "*" rule. Parsing failures are not signature failures — the delivery already +// verified — so this never affects OK. +func slackEventType(body []byte) string { + var p struct { + Type string `json:"type"` + Event struct { + Type string `json:"type"` + } `json:"event"` + } + if err := json.Unmarshal(body, &p); err != nil { + return "" + } + if p.Event.Type != "" { + return p.Event.Type + } + return p.Type } diff --git a/internal/webhookverify/slack_test.go b/internal/webhookverify/slack_test.go index 579a5e8e6a..104253233a 100644 --- a/internal/webhookverify/slack_test.go +++ b/internal/webhookverify/slack_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/hex" "fmt" + "math" "testing" "time" @@ -144,3 +145,57 @@ func TestSlackV0_ReplayWindowClampedToMax(t *testing.T) { t.Fatalf("a pack replay_window=1000h must be clamped to %s; a stale delivery past the max must be rejected", maxReplayWindow) } } + +// The Slack event type is derived from the verified body so payload-carried +// event rules (e.g. event = "message") actually match: the nested event.type +// for an event_callback, else the top-level type. +func TestSlackV0_EventTypeFromBody(t *testing.T) { + secret := slackTestSecret + now := time.Unix(1_700_000_000, 0) + ts := fmt.Sprintf("%d", now.Unix()) + clock := func() time.Time { return now.Add(10 * time.Second) } + v, _ := New("slack-v0", config.WebhookVerify{}, Options{}) + + body := []byte(`{"type":"event_callback","event":{"type":"message"}}`) + res, err := v.Verify(context.Background(), VerifyRequest{Body: body, Secret: []byte(secret), Header: hdr(slackSignatureHeader, slackSign(ts, body), slackTimestampHeader, ts), Now: clock}) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if !res.OK { + t.Fatalf("expected OK, reason %q", res.Reason) + } + if res.EventType != "message" { + t.Errorf("EventType = %q, want the nested event.type %q", res.EventType, "message") + } + + body2 := []byte(`{"type":"url_verification","challenge":"c"}`) + res2, _ := v.Verify(context.Background(), VerifyRequest{Body: body2, Secret: []byte(secret), Header: hdr(slackSignatureHeader, slackSign(ts, body2), slackTimestampHeader, ts), Now: clock}) + if !res2.OK { + t.Fatalf("expected OK, reason %q", res2.Reason) + } + if res2.EventType != "url_verification" { + t.Errorf("EventType = %q, want the top-level type %q", res2.EventType, "url_verification") + } +} + +// Regression: a far-future signed timestamp must be rejected. now.Sub(future) +// clamps to math.MinInt64 and negating it stays negative, so a naive +// abs(skew) > window check would silently PASS a maximally-future timestamp. +func TestSlackV0_FarFutureTimestampRejected(t *testing.T) { + secret := slackTestSecret + body := []byte(`{"type":"event_callback"}`) + ts := fmt.Sprintf("%d", int64(math.MaxInt64)) + sig := slackSign(ts, body) + v, _ := New("slack-v0", config.WebhookVerify{}, Options{}) + res, err := v.Verify(context.Background(), VerifyRequest{ + Body: body, Secret: []byte(secret), + Header: hdr(slackSignatureHeader, sig, slackTimestampHeader, ts), + Now: func() time.Time { return time.Unix(1_700_000_000, 0) }, + }) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if res.OK { + t.Fatal("a far-future signed timestamp must be rejected, not clamp-underflow past the replay window") + } +} diff --git a/internal/webhookverify/verify.go b/internal/webhookverify/verify.go index 964cf64bf9..688593558d 100644 --- a/internal/webhookverify/verify.go +++ b/internal/webhookverify/verify.go @@ -52,9 +52,12 @@ type VerifyResult struct { // OK is true only when the delivery is cryptographically authentic and all // scheme-specific replay/claim checks passed. OK bool - // EventType is the provider event type when the scheme surfaces it from a - // header (e.g. X-GitHub-Event). Payload-derived event typing is the rule - // layer's job (E5) and is left empty here. + // EventType is the resolved provider event type the rule layer (E5) matches + // on. A header-typed scheme surfaces it from the configured header (e.g. + // X-GitHub-Event); a body-typed scheme (slack-v0, discord-ed25519) derives it + // from the VERIFIED body so payload-carried event rules actually match — see + // slackEventType / discordEventType. Empty when the scheme carries no type or + // the body is not the expected shape, in which case only a "*" rule matches. EventType string // DedupID is a stable per-delivery identifier for at-least-once dedup when // the scheme exposes one (e.g. X-GitHub-Delivery, the Slack timestamp, or @@ -186,3 +189,21 @@ func resolveReplayWindow(raw string, def time.Duration) (time.Duration, error) { } return w, nil } + +// withinReplayWindow reports whether a signed unix-second timestamp is within +// window of now. It compares integer seconds and bounds tsSecs against +// [now-window, now+window] rather than subtracting attacker-controlled values, +// because time.Time.Sub clamps a far-future/past difference to +// math.MinInt64/math.MaxInt64 — and negating math.MinInt64 stays negative, so a +// naive `abs(skew) > window` check would silently PASS a far-future timestamp +// (its clamped-negative "skew" never exceeds the window). now.Unix() is a real +// wall-clock value and window is bounded by maxReplayWindow, so now±windowSecs +// cannot overflow; tsSecs appears only in comparisons, so any int64 is safe. +func withinReplayWindow(now time.Time, tsSecs int64, window time.Duration) bool { + windowSecs := int64(window / time.Second) + if windowSecs < 0 { + windowSecs = 0 + } + nowSecs := now.Unix() + return tsSecs >= nowSecs-windowSecs && tsSecs <= nowSecs+windowSecs +} From 718ecc9cf644fc4d8cfb2be9d5d43468f74bde26 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 8 Jul 2026 20:07:03 -0700 Subject: [PATCH 017/225] simplify(S38): durable gc.control_for lineage (retire findLatestAttempt ref-string surgery) (#4044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What this does Lands **S38** (part of the simplification track, #3789): replaces `findLatestAttempt`'s four-stage dotted-step-ref string surgery with a durable `gc.control_for` lineage stamp. Every attempt/iteration root is now stamped with `gc.control_for` at mint time (compile-time seeds in `formula/retry.go` + `formula/ralph.go`; runtime mints in `dispatch/control.go:buildAttemptRecipe`, written *after* the metadata copy loop so a formula-authored value can't shadow it). Attempt-lineage recovery then becomes one string equality against the control's identity set `{ID, gc.step_ref, gc.step_id}` plus an integer `max(gc.attempt)` — no ref parsing. Clone coherence for in-iteration ralph retries is kept via a post-create bead-ID remap (`dispatch/ralph.go`) and `MetadataRefs`. Leans on the existing `beadmeta.ControlForMetadataKey` (`gc.control_for`) — no new metadata key, no wire/event changes. Confined to `internal/dispatch` + `internal/formula`; typed-wire/events, worker boundary, `config.Agent` field-sync, and `cmd/gc` projections are untouched. ## Behavior-preserving The dense four-stage cascade is **demoted**, not deleted: `latestAttemptFromCandidatesLegacyRefSurgery` is moved verbatim and marked DEPRECATED, invoked only as a guarded fallback when no candidate carries a stamp (pre-S38 in-flight molecules). A package-level `legacyAttemptLineageHits` counter makes the pre-stamp drain observable. Existing unstamped fixtures resolve through this fallback unchanged. **Phase 4 (cascade deletion, ~80 LOC of the densest dispatch code) is deferred** to the release after the legacy-hit counter drains to zero in production — this PR is Phases 1-3 (write, clone-coherence, read-flip-with-fallback). ## Tests Stamp coverage per mint path (T1), read-side table test (T2), shadow parity primary==legacy on stamped fixtures (T3), legacy-population fallback + counter (T4), and W6/W7 clone-remap mechanisms (T5). ## Notes - Rebased onto current `main` (over S12/S13/S37/S04/S09/S06/S26). S37 (`dispatch/fanout.go`+`runtime.go`) and S13 (`sling/`) touch different files than S38 (`dispatch/control.go`+`ralph.go`), so the rebase was clean; S38 semantics unchanged. - Spec: `engdocs/simplification/specs/S38-control-for-lineage-spec.md` (on the simplification audit branch). Co-Authored-By: Claude Opus 4.8 (1M context) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- internal/dispatch/control.go | 100 ++++- internal/dispatch/control_for_lineage_test.go | 416 ++++++++++++++++++ internal/dispatch/ralph.go | 43 +- internal/formula/ralph.go | 26 +- internal/formula/ralph_test.go | 122 +++++ internal/formula/retry.go | 6 + internal/formula/retry_test.go | 33 ++ 7 files changed, 738 insertions(+), 8 deletions(-) create mode 100644 internal/dispatch/control_for_lineage_test.go diff --git a/internal/dispatch/control.go b/internal/dispatch/control.go index f095a9ce8c..d3225c70c4 100644 --- a/internal/dispatch/control.go +++ b/internal/dispatch/control.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strconv" "strings" + "sync/atomic" "github.com/gastownhall/gascity/internal/beadmeta" "github.com/gastownhall/gascity/internal/beads" @@ -655,6 +656,13 @@ func buildAttemptRecipe(step *formula.Step, control beads.Bead, attemptNum int) rootMeta[beadmeta.AttemptMetadataKey] = strconv.Itoa(attemptNum) rootMeta[beadmeta.StepIDMetadataKey] = stepID rootMeta[beadmeta.StepRefMetadataKey] = attemptPrefix + // gc.control_for is the durable lineage pointer back to the control bead. + // Written AFTER the step.Metadata copy loop so a formula-authored value + // cannot shadow it. control.ID is a real store bead ID for top-level mints + // and the control's namespaced step ref for nested seeds + // (buildNestedControlSeed) — both are covered by findLatestAttempt's + // identity set. + rootMeta[beadmeta.ControlForMetadataKey] = control.ID if step.OnComplete != nil { rootMeta[beadmeta.OutputJSONRequiredMetadataKey] = "true" } @@ -1364,9 +1372,11 @@ func isFailedPartialMolecule(bead beads.Bead) bool { return strings.TrimSpace(bead.Metadata["molecule_failed"]) == "true" } -// findLatestAttempt finds the most recent attempt/iteration child of a control bead. -// Matches by gc.step_ref pattern: the attempt's step_ref ends with -// .attempt.N or .iteration.N where the prefix matches the control's step_ref. +// findLatestAttempt finds the most recent attempt/iteration child of a control +// bead. It lists beads under the workflow root and, on empty result, walks the +// control's blocks-dependencies; both feed latestAttemptFromCandidates, which +// matches the durable gc.control_for lineage stamp (with a legacy ref-string +// fallback for pre-S38 molecules) and returns the max gc.attempt. func findLatestAttempt(store beads.Store, control beads.Bead) (beads.Bead, error) { rootID := control.Metadata[beadmeta.RootBeadIDMetadataKey] if rootID == "" { @@ -1413,7 +1423,88 @@ func latestAttemptFromDependencies(store beads.Store, control beads.Bead) (beads return latestAttemptFromCandidates(control, candidates), nil } +// latestAttemptFromCandidates selects the control's latest attempt/iteration +// root among candidates. +// +// Primary path (S38): match the durable gc.control_for lineage stamp against +// the control's identity set — one string equality plus an integer max, no ref +// parsing. Every attempt/iteration root minted since S38 carries this stamp +// (buildAttemptRecipe and the compile-time first-attempt seeds). When no +// candidate carries a matching stamp (in-flight molecules minted before S38), +// it falls back to the deprecated ref-string cascade. func latestAttemptFromCandidates(control beads.Bead, candidates []beads.Bead) beads.Bead { + identity := controlIdentitySet(control) + + var latest beads.Bead + latestAttempt := 0 + for _, b := range candidates { + if isFailedPartialMolecule(b) { + continue + } + // Skip beads that are control infrastructure, not actual work. On the + // primary path only this control's own attempt roots carry its identity, + // so no scope-unless-ralph skip is needed (see legacy fallback). + if latestAttemptCandidateIsControlInfrastructure(b.Metadata[beadmeta.KindMetadataKey]) { + continue + } + cf := strings.TrimSpace(b.Metadata[beadmeta.ControlForMetadataKey]) + if cf == "" || !identity[cf] { + continue + } + attemptNum, _ := strconv.Atoi(b.Metadata[beadmeta.AttemptMetadataKey]) + if attemptNum > latestAttempt { + latestAttempt = attemptNum + latest = b + } + } + if latest.ID != "" { + return latest + } + return latestAttemptFromCandidatesLegacyRefSurgery(control, candidates) +} + +// controlIdentitySet returns the non-empty members of the control's identity: +// its store bead ID plus its namespaced step ref and bare step id. A +// gc.control_for stamp equal to any member points at this control (bead-ID +// stamps come from runtime top-level mints; step-ref/step-id stamps come from +// compile-time and nested seeds — see S38). +func controlIdentitySet(control beads.Bead) map[string]bool { + identity := make(map[string]bool, 3) + for _, v := range []string{ + control.ID, + control.Metadata[beadmeta.StepRefMetadataKey], + control.Metadata[beadmeta.StepIDMetadataKey], + } { + if v = strings.TrimSpace(v); v != "" { + identity[v] = true + } + } + return identity +} + +// legacyAttemptLineageHits counts attempt-lineage recoveries served by the +// deprecated pre-S38 ref-string cascade rather than the gc.control_for stamp. +// It is an in-process test hook, not a production operator surface: the +// deletion gate for the legacy cascade (S38 Phase 4) is enforced by the +// shadow-parity tests proving the primary stamp path subsumes the cascade, +// with this counter asserted to stay at zero over post-S38 candidate shapes. +// Package-level counter (not an event type) per the S38 trace-observability +// note; wire it to a trace/metric before relying on it in production. +var legacyAttemptLineageHits int64 + +// legacyAttemptLineageHitCount reports the number of attempt-lineage recoveries +// served by the deprecated ref-string cascade. In-process test hook. +func legacyAttemptLineageHitCount() int64 { + return atomic.LoadInt64(&legacyAttemptLineageHits) +} + +// latestAttemptFromCandidatesLegacyRefSurgery recovers attempt lineage by +// parsing dotted step refs through a four-stage cascade. +// +// Deprecated: remove after the release following S38 — serves only molecules +// minted before the gc.control_for stamp existed. New attempts resolve on the +// primary equality path in latestAttemptFromCandidates. +func latestAttemptFromCandidatesLegacyRefSurgery(control beads.Bead, candidates []beads.Bead) beads.Bead { controlRef := control.Metadata[beadmeta.StepRefMetadataKey] if controlRef == "" { controlRef = control.ID @@ -1490,6 +1581,9 @@ func latestAttemptFromCandidates(control beads.Bead, candidates []beads.Bead) be latest = b } } + if latest.ID != "" { + atomic.AddInt64(&legacyAttemptLineageHits, 1) + } return latest } diff --git a/internal/dispatch/control_for_lineage_test.go b/internal/dispatch/control_for_lineage_test.go new file mode 100644 index 0000000000..5dc8a1650e --- /dev/null +++ b/internal/dispatch/control_for_lineage_test.go @@ -0,0 +1,416 @@ +package dispatch + +import ( + "testing" + + "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/formula" +) + +// TestBuildAttemptRecipeStampsControlFor asserts W4/W5: buildAttemptRecipe +// stamps gc.control_for = control.ID on the attempt root, after the +// step.Metadata copy loop so a formula-authored value cannot shadow it. +func TestBuildAttemptRecipeStampsControlFor(t *testing.T) { + t.Parallel() + + t.Run("top-level mint uses control bead ID", func(t *testing.T) { + step := &formula.Step{ + ID: "review", + Type: "task", + Retry: &formula.RetrySpec{MaxAttempts: 3}, + // A formula-authored control_for must NOT survive — the stamp is + // written after the copy loop. + Metadata: map[string]string{"gc.control_for": "formula-authored-junk"}, + } + control := beads.Bead{ + ID: "gc-control-1", + Metadata: map[string]string{ + "gc.step_id": "review", + "gc.step_ref": "mol-test.review", + }, + } + recipe := buildAttemptRecipe(step, control, 2) + root := recipe.Steps[0] + if got := root.Metadata["gc.control_for"]; got != "gc-control-1" { + t.Fatalf("root gc.control_for = %q, want gc-control-1 (formula value must be overridden)", got) + } + }) + + t.Run("nested seed uses namespaced child ref", func(t *testing.T) { + child := &formula.Step{ + ID: "inner", + Type: "task", + Ralph: &formula.RalphSpec{MaxAttempts: 2, Check: &formula.RalphCheckSpec{Mode: "exec", Path: "c.sh"}}, + } + // buildNestedControlSeed mints via a synthetic control whose .ID is the + // namespaced child ref; W4's stamp yields that ref. + synthetic := beads.Bead{ + ID: "mol.outer.iteration.2.inner", + Metadata: map[string]string{"gc.step_id": "inner", "gc.step_ref": "mol.outer.iteration.2.inner"}, + } + recipe := buildAttemptRecipe(child, synthetic, 1) + root := recipe.Steps[0] + if got := root.Metadata["gc.control_for"]; got != "mol.outer.iteration.2.inner" { + t.Fatalf("nested seed gc.control_for = %q, want mol.outer.iteration.2.inner", got) + } + }) +} + +// controlBead is a small helper to build a retry control bead with an identity +// set (store ID, namespaced step_ref, bare step_id). +func controlBead(id, stepRef, stepID string) beads.Bead { + return beads.Bead{ID: id, Metadata: map[string]string{ + "gc.kind": "retry", + "gc.step_ref": stepRef, + "gc.step_id": stepID, + }} +} + +// stampedAttempt builds an attempt-root candidate carrying gc.control_for. +func stampedAttempt(id, controlFor string, attempt string, kind string) beads.Bead { + m := map[string]string{ + "gc.control_for": controlFor, + "gc.attempt": attempt, + } + if kind != "" { + m["gc.kind"] = kind + } + return beads.Bead{ID: id, Metadata: m} +} + +// TestLatestAttemptFromCandidatesPrimary is the T2 read-side table test: the +// primary path matches gc.control_for against the control identity set, skips +// infrastructure and molecule_failed, and selects max(gc.attempt). +func TestLatestAttemptFromCandidatesPrimary(t *testing.T) { + control := controlBead("gc-ctl", "mol.review", "review") + + tests := []struct { + name string + candidates []beads.Bead + wantID string + }{ + { + name: "match by bead ID", + candidates: []beads.Bead{stampedAttempt("a1", "gc-ctl", "1", "")}, + wantID: "a1", + }, + { + name: "match by step_ref", + candidates: []beads.Bead{stampedAttempt("a1", "mol.review", "1", "")}, + wantID: "a1", + }, + { + name: "match by step_id", + candidates: []beads.Bead{stampedAttempt("a1", "review", "1", "")}, + wantID: "a1", + }, + { + name: "max attempt wins", + candidates: []beads.Bead{ + stampedAttempt("a1", "gc-ctl", "1", ""), + stampedAttempt("a3", "gc-ctl", "3", ""), + stampedAttempt("a2", "gc-ctl", "2", ""), + }, + wantID: "a3", + }, + { + name: "molecule_failed skipped", + candidates: []beads.Bead{ + func() beads.Bead { + b := stampedAttempt("a2", "gc-ctl", "2", "") + b.Metadata["molecule_failed"] = "true" + return b + }(), + stampedAttempt("a1", "gc-ctl", "1", ""), + }, + wantID: "a1", + }, + { + name: "infrastructure kind carrying same control_for is not selected", + candidates: []beads.Bead{ + // A scope-check control whose control_for equals the retry step + // ref must NOT be picked even though it has a higher attempt. + stampedAttempt("chk", "mol.review", "9", "scope-check"), + stampedAttempt("a1", "gc-ctl", "1", ""), + }, + wantID: "a1", + }, + { + name: "non-matching control_for ignored", + candidates: []beads.Bead{stampedAttempt("a1", "gc-other", "1", "")}, + wantID: "", // no primary match, no legacy ref → empty + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := latestAttemptFromCandidates(control, tc.candidates) + if got.ID != tc.wantID { + t.Fatalf("latestAttemptFromCandidates = %q, want %q", got.ID, tc.wantID) + } + }) + } +} + +// TestLatestAttemptShadowParity is the I1 deletion-gate check (T3): whenever a +// candidate set carries stamps, the primary path selects exactly what the +// legacy ref-string cascade would have. +func TestLatestAttemptShadowParity(t *testing.T) { + control := controlBead("gc-ctl", "mol-feature.review", "review") + + // Build candidates that BOTH the legacy ref parser and the stamp resolve: + // step_ref shaped as the legacy cascade expects AND carrying the stamp. + mk := func(id, stepRef, attempt string) beads.Bead { + return beads.Bead{ID: id, Metadata: map[string]string{ + "gc.step_ref": stepRef, + "gc.attempt": attempt, + "gc.control_for": "gc-ctl", + }} + } + candidates := []beads.Bead{ + mk("a1", "mol-feature.review.attempt.1", "1"), + mk("a2", "mol-feature.review.attempt.2", "2"), + } + + primary := latestAttemptFromCandidates(control, candidates) + legacy := latestAttemptFromCandidatesLegacyRefSurgery(control, candidates) + if primary.ID != legacy.ID { + t.Fatalf("shadow parity broken: primary=%q legacy=%q", primary.ID, legacy.ID) + } + if primary.ID != "a2" { + t.Fatalf("primary selected %q, want a2", primary.ID) + } +} + +// TestLatestAttemptLegacyFallbackForUnstamped is T4: candidates minted before +// the stamp (no gc.control_for) still resolve via the guarded legacy cascade, +// and the in-process legacy-hit counter advances so the deletion-gate tests +// can observe legacy usage. +func TestLatestAttemptLegacyFallbackForUnstamped(t *testing.T) { + control := controlBead("gc-ctl", "mol-feature.review", "review") + + // Pre-stamp shapes: legacy ref surgery only. + candidates := []beads.Bead{ + {ID: "a1", Metadata: map[string]string{"gc.step_ref": "mol-feature.review.attempt.1", "gc.attempt": "1"}}, + {ID: "a2", Metadata: map[string]string{"gc.step_ref": "mol-feature.review.attempt.2", "gc.attempt": "2"}}, + } + + before := legacyAttemptLineageHitCount() + got := latestAttemptFromCandidates(control, candidates) + if got.ID != "a2" { + t.Fatalf("legacy fallback selected %q, want a2", got.ID) + } + if after := legacyAttemptLineageHitCount(); after <= before { + t.Fatalf("legacy-hit counter did not advance: before=%d after=%d", before, after) + } +} + +// TestLatestAttemptStampedShapesNeverHitLegacyCascade is the executable form of +// the S38 Phase-4 deletion gate: over post-S38 candidate shapes — every +// attempt/iteration root carries a gc.control_for stamp matching its control's +// identity — latestAttemptFromCandidates resolves on the primary equality path +// and never falls through to latestAttemptFromCandidatesLegacyRefSurgery, so +// legacyAttemptLineageHitCount() stays unchanged. This is the "stays at zero +// over post-S38 candidate shapes" guarantee the legacyAttemptLineageHits comment +// relies on; the advancing-counter test (unstamped shapes) and the shadow-parity +// tests (which call the legacy cascade directly) do not prove it. Serial (no +// t.Parallel) so the package-global counter delta is observed without +// interference — dispatch spawns no background goroutine that touches it, and Go +// defers every parallel test until the serial phase completes. +func TestLatestAttemptStampedShapesNeverHitLegacyCascade(t *testing.T) { + simpleControl := controlBead("gc-ctl", "mol-feature.review", "review") + iter1Control := controlBead( + "mol.review-loop.iteration.1.inner", + "review-loop.iteration.1.inner", "inner") + iter2Control := controlBead( + "mol.review-loop.iteration.2.inner", + "mol.review-loop.iteration.2.inner", "inner") + + // Fully-stamped candidates covering the simple and nested shapes. Each control + // matches its own attempt roots on the gc.control_for identity, so the primary + // path returns non-empty and the legacy cascade is never reached. No + // gc.step_ref is set, so the counter can only move if a primary match is + // missing — exactly what this gate forbids for stamped shapes. + stamped := []beads.Bead{ + stampedAttempt("a1", "gc-ctl", "1", ""), + stampedAttempt("a2", "gc-ctl", "2", ""), + stampedAttempt("i1a1", "review-loop.iteration.1.inner", "1", ""), + stampedAttempt("i1a2", "review-loop.iteration.1.inner", "2", ""), + stampedAttempt("i2a1", "mol.review-loop.iteration.2.inner", "1", ""), + } + + for _, tc := range []struct { + name string + control beads.Bead + wantID string + }{ + {"simple retry control", simpleControl, "a2"}, + {"nested outer-iteration-1 inner", iter1Control, "i1a2"}, + {"nested outer-iteration-2 inner", iter2Control, "i2a1"}, + } { + t.Run(tc.name, func(t *testing.T) { + before := legacyAttemptLineageHitCount() + got := latestAttemptFromCandidates(tc.control, stamped) + if got.ID != tc.wantID { + t.Fatalf("latestAttemptFromCandidates = %q, want %q", got.ID, tc.wantID) + } + if after := legacyAttemptLineageHitCount(); after != before { + t.Fatalf("stamped shape hit the deprecated cascade: legacy-hit counter advanced before=%d after=%d", before, after) + } + }) + } +} + +// TestRemappedControlForBeadID covers the W6 remap helper: bead-ID pointers at +// re-minted beads map to the new ID; step-ref pointers and pointers outside the +// clone set return "" (left to rewriteRetryControlFor / untouched). +func TestRemappedControlForBeadID(t *testing.T) { + t.Parallel() + mapping := map[string]string{"old-ctl": "new-ctl"} + + if got := remappedControlForBeadID(mapping, "old-ctl"); got != "new-ctl" { + t.Fatalf("bead-ID in mapping = %q, want new-ctl", got) + } + if got := remappedControlForBeadID(mapping, "mol.outer.iteration.1.inner"); got != "" { + t.Fatalf("step-ref value = %q, want empty (not remapped)", got) + } + if got := remappedControlForBeadID(mapping, "some-other-bead"); got != "" { + t.Fatalf("bead-ID outside clone set = %q, want empty", got) + } + if got := remappedControlForBeadID(mapping, ""); got != "" { + t.Fatalf("empty value = %q, want empty", got) + } +} + +// TestBuildRalphRetryGraphNodeControlForRemap covers W7: a bead-ID-valued +// gc.control_for pointing at a bead re-minted in the plan is moved to +// MetadataRefs (so the applier substitutes the new ID); a step-ref-valued +// pointer stays on the string rewrite path in meta. +func TestBuildRalphRetryGraphNodeControlForRemap(t *testing.T) { + t.Parallel() + + attemptIDs := map[string]bool{"nested-ctl": true, "subject-old": true} + + t.Run("bead-ID pointer moves to MetadataRefs", func(t *testing.T) { + old := beads.Bead{ + ID: "attempt-old", + Ref: "mol.loop.iteration.1.inner.attempt.1", + Metadata: map[string]string{ + "gc.control_for": "nested-ctl", + "gc.attempt": "1", + }, + } + node := buildRalphRetryGraphNode(old, "logical", "mol.loop.iteration.1", "mol.loop.iteration.2", 1, 2, attemptIDs, nil) + if _, ok := node.Metadata["gc.control_for"]; ok { + t.Fatalf("gc.control_for must be removed from Metadata when remapped, got %q", node.Metadata["gc.control_for"]) + } + if node.MetadataRefs["gc.control_for"] != "nested-ctl" { + t.Fatalf("MetadataRefs[gc.control_for] = %q, want nested-ctl", node.MetadataRefs["gc.control_for"]) + } + }) + + t.Run("step-ref pointer stays in Metadata", func(t *testing.T) { + old := beads.Bead{ + ID: "check-old", + Ref: "mol.loop.iteration.1.check", + Metadata: map[string]string{ + "gc.control_for": "mol.loop.iteration.1.inner", + "gc.attempt": "1", + }, + } + node := buildRalphRetryGraphNode(old, "logical", "mol.loop.iteration.1", "mol.loop.iteration.2", 1, 2, attemptIDs, nil) + if node.MetadataRefs["gc.control_for"] != "" { + t.Fatalf("step-ref pointer must not go to MetadataRefs, got %q", node.MetadataRefs["gc.control_for"]) + } + if node.Metadata["gc.control_for"] == "" { + t.Fatalf("step-ref pointer must remain in Metadata") + } + }) +} + +// TestLatestAttemptNestedControlIsolatedAcrossOuterIterations is the S38 +// nested-lineage regression guard for the read side: each outer ralph +// iteration's inner control must resolve ONLY its own latest attempt, never a +// sibling outer iteration's, even when the sibling has a higher gc.attempt. +// +// Shapes match what the producers emit after the fix: outer iteration 1 is the +// compile-time seed (non-mol-prefixed inner step_ref, so its attempt roots +// carry the namespaced ref "review-loop.iteration.1.inner"); outer iteration 2 +// is the runtime buildNestedControlSeed (mol-prefixed inner ref). Before the +// fix both iterations' attempt roots carried the bare "inner" stamp, so the +// iteration-2 lookup matched iteration-1's attempt.2 through the shared +// gc.step_id identity member and the max(gc.attempt) tiebreak. +func TestLatestAttemptNestedControlIsolatedAcrossOuterIterations(t *testing.T) { + iter1Control := controlBead( + "mol.review-loop.iteration.1.inner", + "review-loop.iteration.1.inner", "inner") + iter2Control := controlBead( + "mol.review-loop.iteration.2.inner", + "mol.review-loop.iteration.2.inner", "inner") + + candidates := []beads.Bead{ + // Outer iteration 1's inner retried once: attempt.1 and attempt.2. + stampedAttempt("i1a1", "review-loop.iteration.1.inner", "1", ""), + stampedAttempt("i1a2", "review-loop.iteration.1.inner", "2", ""), + // Outer iteration 2's inner has only attempt.1 (lower than i1's max). + stampedAttempt("i2a1", "mol.review-loop.iteration.2.inner", "1", ""), + } + + if got := latestAttemptFromCandidates(iter1Control, candidates); got.ID != "i1a2" { + t.Fatalf("iteration-1 inner resolved %q, want i1a2 (its own latest attempt)", got.ID) + } + // Decisive assertion: iteration-2 inner must not pick up iteration-1's + // higher-numbered attempt through a shared bare step id. + if got := latestAttemptFromCandidates(iter2Control, candidates); got.ID != "i2a1" { + t.Fatalf("iteration-2 inner resolved %q, want i2a1 (must not match sibling iteration-1 attempt.2)", got.ID) + } +} + +// TestLatestAttemptShadowParityNested extends the I1 deletion-gate check to the +// nested-control shape the reviewers flagged: with namespaced gc.control_for +// stamps and legacy-shaped gc.step_refs present, the primary stamp path and the +// deprecated ref-string cascade must select the SAME per-iteration attempt +// root. This is the shape where a bare stamp made them diverge before S38. +func TestLatestAttemptShadowParityNested(t *testing.T) { + iter1Control := controlBead( + "mol.review-loop.iteration.1.inner", + "review-loop.iteration.1.inner", "inner") + iter2Control := controlBead( + "mol.review-loop.iteration.2.inner", + "mol.review-loop.iteration.2.inner", "inner") + + // Candidates carry BOTH the namespaced stamp (primary) and a legacy-shaped + // step_ref (ref cascade), so the two paths can be compared directly. + mk := func(id, controlFor, stepRef, attempt string) beads.Bead { + return beads.Bead{ID: id, Metadata: map[string]string{ + "gc.control_for": controlFor, + "gc.step_ref": stepRef, + "gc.attempt": attempt, + }} + } + candidates := []beads.Bead{ + mk("i1a1", "review-loop.iteration.1.inner", "review-loop.iteration.1.inner.attempt.1", "1"), + mk("i1a2", "review-loop.iteration.1.inner", "review-loop.iteration.1.inner.attempt.2", "2"), + mk("i2a1", "mol.review-loop.iteration.2.inner", "mol.review-loop.iteration.2.inner.attempt.1", "1"), + } + + for _, tc := range []struct { + name string + control beads.Bead + wantID string + }{ + {"outer-iteration-1 inner", iter1Control, "i1a2"}, + {"outer-iteration-2 inner", iter2Control, "i2a1"}, + } { + t.Run(tc.name, func(t *testing.T) { + primary := latestAttemptFromCandidates(tc.control, candidates) + legacy := latestAttemptFromCandidatesLegacyRefSurgery(tc.control, candidates) + if primary.ID != legacy.ID { + t.Fatalf("shadow parity broken: primary=%q legacy=%q", primary.ID, legacy.ID) + } + if primary.ID != tc.wantID { + t.Fatalf("resolved %q, want %q (own iteration's latest attempt)", primary.ID, tc.wantID) + } + }) + } +} diff --git a/internal/dispatch/ralph.go b/internal/dispatch/ralph.go index 3130fdffe8..74972603eb 100644 --- a/internal/dispatch/ralph.go +++ b/internal/dispatch/ralph.go @@ -576,12 +576,22 @@ func appendRalphRetryLegacy(store beads.Store, logicalID string, prevSubject, pr return nil, fmt.Errorf("remapping logical bead for retry clone %s: %w", newID, err) } } + if remapped := remappedControlForBeadID(mapping, old.Metadata[beadmeta.ControlForMetadataKey]); remapped != "" { + if err := store.SetMetadata(newID, beadmeta.ControlForMetadataKey, remapped); err != nil { + return nil, fmt.Errorf("remapping control_for for retry clone %s: %w", newID, err) + } + } } if remapped := remappedLogicalBeadID(mapping, prevCheck.Metadata[beadmeta.LogicalBeadIDMetadataKey]); remapped != "" { if err := store.SetMetadata(newCheck.ID, beadmeta.LogicalBeadIDMetadataKey, remapped); err != nil { return nil, fmt.Errorf("remapping logical bead for retry check %s: %w", newCheck.ID, err) } } + if remapped := remappedControlForBeadID(mapping, prevCheck.Metadata[beadmeta.ControlForMetadataKey]); remapped != "" { + if err := store.SetMetadata(newCheck.ID, beadmeta.ControlForMetadataKey, remapped); err != nil { + return nil, fmt.Errorf("remapping control_for for retry check %s: %w", newCheck.ID, err) + } + } for _, old := range ordered { if err := copyRetryDeps(store, old.ID, mapping[old.ID], mapping); err != nil { @@ -681,13 +691,25 @@ func buildRalphRetryGraphNode(old beads.Bead, logicalID, oldScopeRef, newScopeRe meta[beadmeta.ScopeRefMetadataKey] = rewriteRetryScopeRef(currentScopeRef, oldScopeRef, newScopeRef, old.ID) } meta[beadmeta.StepRefMetadataKey] = rewriteRetryStepRef(meta, old.Ref, oldScopeRef, newScopeRef, oldAttempt, nextAttempt) + metadataRefs := map[string]string(nil) + // gc.control_for: a bead-ID-valued pointer at a bead re-minted in this plan + // is remapped to the clone's new ID via MetadataRefs (the applier + // substitutes the created ID), mirroring gc.logical_bead_id below (S38 W7). + // Step-ref-valued pointers stay on the string rewrite. if controlFor := strings.TrimSpace(meta[beadmeta.ControlForMetadataKey]); controlFor != "" { - meta[beadmeta.ControlForMetadataKey] = rewriteRetryControlFor(meta, controlFor, oldScopeRef, newScopeRef, oldAttempt, nextAttempt) + if attemptIDs[controlFor] { + metadataRefs = make(map[string]string, 1) + metadataRefs[beadmeta.ControlForMetadataKey] = controlFor + delete(meta, beadmeta.ControlForMetadataKey) + } else { + meta[beadmeta.ControlForMetadataKey] = rewriteRetryControlFor(meta, controlFor, oldScopeRef, newScopeRef, oldAttempt, nextAttempt) + } } - metadataRefs := map[string]string(nil) if oldLogicalID := strings.TrimSpace(old.Metadata[beadmeta.LogicalBeadIDMetadataKey]); oldLogicalID != "" { if attemptIDs[oldLogicalID] { - metadataRefs = make(map[string]string, 1) + if metadataRefs == nil { + metadataRefs = make(map[string]string, 1) + } metadataRefs[beadmeta.LogicalBeadIDMetadataKey] = oldLogicalID delete(meta, beadmeta.LogicalBeadIDMetadataKey) } else { @@ -1142,6 +1164,21 @@ func remappedLogicalBeadID(mapping map[string]string, raw string) string { return logicalID } +// remappedControlForBeadID returns the new bead ID for a bead-ID-valued +// gc.control_for pointer that referenced a bead re-minted in this retry clone +// (i.e. the old value is a mapping key). It returns "" for step-ref-valued +// pointers and for bead IDs outside the clone set — those keep the value +// produced by rewriteRetryControlFor at clone time. This mirrors the +// gc.logical_bead_id remap so cloned attempt roots point at the cloned +// nested control's NEW bead ID (S38 W6). +func remappedControlForBeadID(mapping map[string]string, raw string) string { + controlFor := strings.TrimSpace(raw) + if controlFor == "" { + return "" + } + return mapping[controlFor] +} + func resolveExistingRalphRetryFromBeads(store beads.Store, all []beads.Bead, logicalID string, prevSubject, prevCheck beads.Bead, attemptSet map[string]beads.Bead, oldAttempt, nextAttempt int, oldScopeRef, newScopeRef string) (map[string]string, error) { rootID := prevSubject.Metadata[beadmeta.RootBeadIDMetadataKey] if rootID == "" { diff --git a/internal/formula/ralph.go b/internal/formula/ralph.go index 0c166dbb2e..e3bbe91e73 100644 --- a/internal/formula/ralph.go +++ b/internal/formula/ralph.go @@ -3,6 +3,7 @@ package formula import ( "fmt" "strconv" + "strings" "github.com/gastownhall/gascity/internal/beadmeta" ) @@ -101,6 +102,9 @@ func expandRalph(step *Step) ([]*Step, error) { beadmeta.StepIDMetadataKey: step.ID, beadmeta.RalphStepIDMetadataKey: step.ID, beadmeta.StepRefMetadataKey: iterationID, + // gc.control_for is the durable lineage pointer to the ralph control + // (step.ID here, which the control carries as gc.step_id). + beadmeta.ControlForMetadataKey: step.ID, }) delete(iteration.Metadata, beadmeta.ScopeRefMetadataKey) delete(iteration.Metadata, beadmeta.ScopeRoleMetadataKey) @@ -142,6 +146,9 @@ func expandNestedRalph(step, control, specStep *Step, iterationID string, attemp beadmeta.RalphStepIDMetadataKey: step.ID, beadmeta.AttemptMetadataKey: strconv.Itoa(attempt), beadmeta.StepRefMetadataKey: iterationID, + // gc.control_for on the scope root only (body children hang off it via + // gc.scope_ref and are not attempt roots — they must not be stamped). + beadmeta.ControlForMetadataKey: step.ID, }) if step.OnComplete != nil { iteration.Metadata[beadmeta.OutputJSONRequiredMetadataKey] = "true" @@ -193,7 +200,7 @@ func namespaceRalphBodySteps(steps []*Step, iterationID string, owner *Step, att if childStepID == "" { childStepID = node.ID } - clone.Metadata = withMetadata(clone.Metadata, map[string]string{ + childMeta := map[string]string{ beadmeta.ScopeRefMetadataKey: iterationID, beadmeta.OnFailMetadataKey: metadataDefault(node.Metadata, beadmeta.OnFailMetadataKey, "abort_scope"), beadmeta.ScopeRoleMetadataKey: metadataDefault(node.Metadata, beadmeta.ScopeRoleMetadataKey, beadmeta.ScopeRoleMember), @@ -201,7 +208,22 @@ func namespaceRalphBodySteps(steps []*Step, iterationID string, owner *Step, att beadmeta.RalphStepIDMetadataKey: owner.ID, beadmeta.AttemptMetadataKey: strconv.Itoa(attempt), beadmeta.StepRefMetadataKey: clone.ID, - }) + } + // A nested control's attempt/iteration root carries gc.control_for as + // the bare inner-control step id (stamped by expandRetry/expandRalph + // before this body was namespaced). Rewrite it to the namespaced + // control ref (iterationID-prefixed, matching the cloned inner + // control's gc.step_ref above) so findLatestAttempt scopes it to THIS + // outer iteration's inner control instead of matching every sibling + // outer iteration through the shared bare step id. This mirrors the + // runtime buildNestedControlSeed stamp for outer iterations 2+ (both + // yield the inner control's namespaced ref); the bare value only + // remains on top-level attempt roots, where the step id is unique per + // workflow root so no cross-iteration collision exists (S38). + if cf := strings.TrimSpace(node.Metadata[beadmeta.ControlForMetadataKey]); cf != "" { + childMeta[beadmeta.ControlForMetadataKey] = iterationID + "." + cf + } + clone.Metadata = withMetadata(clone.Metadata, childMeta) if top { topLevel = append(topLevel, clone.ID) clone.DependsOn = append(clone.DependsOn, owner.DependsOn...) diff --git a/internal/formula/ralph_test.go b/internal/formula/ralph_test.go index c306bee2b0..fc9df3eb27 100644 --- a/internal/formula/ralph_test.go +++ b/internal/formula/ralph_test.go @@ -698,3 +698,125 @@ func TestMarkRalphBodyOutputSinksTracksBeadmetaExemptKinds(t *testing.T) { t.Error("teardown-role step was marked as an output sink") } } + +func TestApplyRalph_StampsControlForOnIterationRoot(t *testing.T) { + // Simple ralph (no children): iteration.1 work bead carries the stamp. + simple := []*Step{ + { + ID: "implement", + Title: "Implement", + Type: "task", + Ralph: &RalphSpec{MaxAttempts: 3, Check: &RalphCheckSpec{Mode: "exec", Path: "c.sh"}}, + }, + } + got, err := ApplyRalph(simple) + if err != nil { + t.Fatalf("ApplyRalph failed: %v", err) + } + control, iteration := got[0], got[2] + if iteration.Metadata[beadmeta.ControlForMetadataKey] != "implement" { + t.Fatalf("simple iteration gc.control_for = %q, want implement", iteration.Metadata[beadmeta.ControlForMetadataKey]) + } + if control.Metadata[beadmeta.StepIDMetadataKey] != "implement" { + t.Fatalf("control gc.step_id = %q, want implement (must match iteration gc.control_for)", control.Metadata[beadmeta.StepIDMetadataKey]) + } + if _, ok := control.Metadata[beadmeta.ControlForMetadataKey]; ok { + t.Fatalf("control must not carry gc.control_for") + } + + // Nested ralph: only the iteration scope root carries the stamp; body + // children (which are not attempt roots) must not. + nested := []*Step{ + { + ID: "review-loop", + Title: "Review loop", + Type: "task", + Ralph: &RalphSpec{MaxAttempts: 3, Check: &RalphCheckSpec{Mode: "exec", Path: "c.sh"}}, + Children: []*Step{ + {ID: "review", Title: "Review"}, + {ID: "apply", Title: "Apply", Needs: []string{"review"}}, + }, + }, + } + got2, err := ApplyRalph(nested) + if err != nil { + t.Fatalf("ApplyRalph nested failed: %v", err) + } + scope, reviewChild, applyChild := got2[2], got2[3], got2[4] + if scope.Metadata[beadmeta.ControlForMetadataKey] != "review-loop" { + t.Fatalf("nested scope gc.control_for = %q, want review-loop", scope.Metadata[beadmeta.ControlForMetadataKey]) + } + if _, ok := reviewChild.Metadata[beadmeta.ControlForMetadataKey]; ok { + t.Fatalf("body child %q must not carry gc.control_for", reviewChild.ID) + } + if _, ok := applyChild.Metadata[beadmeta.ControlForMetadataKey]; ok { + t.Fatalf("body child %q must not carry gc.control_for", applyChild.ID) + } +} + +// TestApplyRalph_NamespacesNestedControlForAcrossBody is the S38 nested-lineage +// regression guard for the producer side. An outer ralph with a retry child +// must stamp the nested retry's attempt root with the *namespaced* control ref +// (the cloned inner control's gc.step_ref), not the bare inner step id. +// +// The bare step id ("inner") is shared by the inner control of every sibling +// outer ralph iteration, so a bare gc.control_for let findLatestAttempt's +// primary lookup match a foreign iteration's inner control through the shared +// gc.step_id identity member. Namespacing the stamp scopes it to this outer +// iteration's inner control, mirroring the runtime buildNestedControlSeed path. +func TestApplyRalph_NamespacesNestedControlForAcrossBody(t *testing.T) { + // Mirror the compile pipeline order: retries expand before ralph, so the + // ralph body already holds the inner control + attempt beads when + // namespaceRalphBodySteps runs over them. + steps := []*Step{ + { + ID: "review-loop", + Title: "Review loop", + Type: "task", + Ralph: &RalphSpec{MaxAttempts: 3, Check: &RalphCheckSpec{Mode: "exec", Path: "c.sh"}}, + Children: []*Step{ + { + ID: "inner", + Title: "Inner", + Retry: &RetrySpec{MaxAttempts: 2}, + }, + }, + }, + } + retried, err := ApplyRetries(steps) + if err != nil { + t.Fatalf("ApplyRetries: %v", err) + } + expanded, err := ApplyRalph(retried) + if err != nil { + t.Fatalf("ApplyRalph: %v", err) + } + + var innerControl, innerAttempt *Step + for _, s := range expanded { + switch { + case s.ID == "review-loop.iteration.1.inner" && s.Metadata[beadmeta.KindMetadataKey] == beadmeta.KindRetry: + innerControl = s + case s.ID == "review-loop.iteration.1.inner.attempt.1": + innerAttempt = s + } + } + if innerControl == nil { + t.Fatalf("nested inner control not found among expanded steps") + } + if innerAttempt == nil { + t.Fatalf("nested inner attempt root not found among expanded steps") + } + + wantCF := innerControl.Metadata[beadmeta.StepRefMetadataKey] + if wantCF == "" || wantCF == "inner" { + t.Fatalf("inner control gc.step_ref = %q, want a namespaced ref", wantCF) + } + got := innerAttempt.Metadata[beadmeta.ControlForMetadataKey] + if got == "inner" { + t.Fatalf("nested attempt gc.control_for is the bare step id %q; must be namespaced to the inner control ref", got) + } + if got != wantCF { + t.Fatalf("nested attempt gc.control_for = %q, want %q (must equal inner control gc.step_ref)", got, wantCF) + } +} diff --git a/internal/formula/retry.go b/internal/formula/retry.go index 9b8513ec54..8614d274ca 100644 --- a/internal/formula/retry.go +++ b/internal/formula/retry.go @@ -89,6 +89,12 @@ func expandRetry(step *Step) ([]*Step, error) { run.Metadata = withMetadata(run.Metadata, map[string]string{ beadmeta.AttemptMetadataKey: strconv.Itoa(attempt), beadmeta.StepIDMetadataKey: step.ID, + // gc.control_for records the durable lineage pointer back to the retry + // control. At compile time no store bead ID exists yet, so the value is + // the control's identity as known now (step.ID, which the control also + // carries as gc.step_id). findLatestAttempt matches on this metadata + // instead of parsing ref strings. + beadmeta.ControlForMetadataKey: step.ID, // gc.step_ref is NOT set here — molecule.Instantiate fills it from // step.ID which includes the formula prefix (e.g., "mol.finalize.attempt.1" // instead of the bare "finalize.attempt.1"). diff --git a/internal/formula/retry_test.go b/internal/formula/retry_test.go index bed503e5f9..486a33108f 100644 --- a/internal/formula/retry_test.go +++ b/internal/formula/retry_test.go @@ -262,3 +262,36 @@ func TestApplyRetriesFrozenSpecRoundTrips(t *testing.T) { } }) } + +func TestApplyRetriesStampsControlForOnAttemptRoot(t *testing.T) { + steps := []*Step{ + { + ID: "review", + Title: "Review change", + Type: "task", + Retry: &RetrySpec{MaxAttempts: 3}, + }, + } + + got, err := ApplyRetries(steps) + if err != nil { + t.Fatalf("ApplyRetries failed: %v", err) + } + control, spec, attempt := got[0], got[1], got[2] + + // The attempt root carries the durable lineage pointer to the control, + // which equals the control's step id. + if attempt.Metadata["gc.control_for"] != "review" { + t.Fatalf("attempt gc.control_for = %q, want review", attempt.Metadata["gc.control_for"]) + } + if control.Metadata["gc.step_id"] != "review" { + t.Fatalf("control gc.step_id = %q, want review (must match attempt gc.control_for)", control.Metadata["gc.step_id"]) + } + // Control and spec beads are not attempt roots — they must not be stamped. + if _, ok := control.Metadata["gc.control_for"]; ok { + t.Fatalf("control must not carry gc.control_for, got %q", control.Metadata["gc.control_for"]) + } + if _, ok := spec.Metadata["gc.control_for"]; ok { + t.Fatalf("spec must not carry gc.control_for, got %q", spec.Metadata["gc.control_for"]) + } +} From 8c85a4d33aa1090ee8ea2156eb7d8519cffaff77 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 8 Jul 2026 20:08:12 -0700 Subject: [PATCH 018/225] simplify(S16): surface swallowed errors on destructive/routing paths (+ F1/F2/F3 fail-closed follow-ups) (#4023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What this does Lands **S16** — surfaces the seven swallowed errors on the reconciler's destructive/routing paths (trace/log + retry-next-tick; fail CLOSED on destructive paths) — and folds in the three review follow-ups (F1/F2/F3). **Commit 1** (`simplify(S16)`): the original S16 change (sling convoy-recovery fail-closed, reconciler orphan-close fail-closed on liveness error, dispatch route-config error surfacing + lazy cache, drain reload error, attempt-log corruption trace). **Commit 2** (folded follow-ups): - **F1** — direct end-to-end test that the liveness-error fail-closed gate fires in the running reconciler. A healthy liveness observation closes an undesired, dead orphan; an injected observation error keeps the bead open (skip) and logs the guard line. The two runs differ only in the liveness error, isolating the guard. (`TestReconcileOrphanCloseFailsClosedOnLivenessError`.) - **F2** — extend the liveness-error fail-closed gate to the three sibling `!providerAlive` destructive paths S16 left ungated, each mirroring the orphan-close guard (trace `skipped_liveness_error` + skip this tick): **pending-create rollback**, **failed-create close**, **drain-ack finalize**. The plain Ctrl-C drain path is `providerAlive`-only and already safe — left untouched; the misleading orphan-close comment is corrected. - **F3** — distinguish `beads.ErrNotFound` in `needsConvoyRecovery`: a genuinely deleted parent still triggers convoy recovery, while transient parent-read errors keep failing closed against the #2987 duplicate-convoy vector. (`TestNeedsConvoyRecoveryDistinguishesDeletedParent`, both branches.) ## F2 safety (wedge review) At the reconciler call site the liveness handle resolves from the loaded session bead, so a **dead-but-observable** runtime returns `(Running=false, nil)`. `livenessErr` is non-nil **only** on a genuine observation failure (handle construction, or the store re-read in `manager.Get`). So the gate fails closed on real errors and **never** wedges cleanup of a confirmed-dead session (that path keeps `livenessErr == nil` and proceeds). The failure mode under a persistent observation error is "leave the bead open and re-observe next tick" — the safe direction. ## Gates - `go build ./...` — pass - `go vet ./cmd/gc ./internal/sling ./internal/dispatch` — pass - `go test ./internal/sling ./internal/dispatch` — pass - `go test ./cmd/gc` reconciler suite (`-run` reconcile/session/drain/heal/ orphan/liveness/pending/failed/close/trace/convoy) — pass _(The full `cmd/gc` package exceeds the raw 600s `go test` timeout — a known sharding limit per `TESTING.md`, not an assertion failure.)_ ## Review verdict LAND via label PR (`status/needs-review-auto`) — destructive-path behavior change (fail-closed extension); routed through auto-review per the simplification walkthrough decision. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/gc/session_reconciler.go | 68 ++++++++++++++- cmd/gc/session_reconciler_trace_test.go | 102 ++++++++++++++++++++++ cmd/gc/session_reconciler_trace_types.go | 9 ++ internal/dispatch/control.go | 53 ++++++++---- internal/dispatch/control_test.go | 89 +++++++++++++++++++ internal/dispatch/drain.go | 16 +++- internal/dispatch/fanout.go | 2 +- internal/dispatch/ralph.go | 6 +- internal/dispatch/retry.go | 14 +-- internal/dispatch/runtime.go | 42 +++++++++ internal/sling/sling_attachment.go | 85 +++++++++++------- internal/sling/sling_test.go | 106 +++++++++++++++++++++++ 12 files changed, 526 insertions(+), 66 deletions(-) diff --git a/cmd/gc/session_reconciler.go b/cmd/gc/session_reconciler.go index c8fb57bcef..7ba96573e4 100644 --- a/cmd/gc/session_reconciler.go +++ b/cmd/gc/session_reconciler.go @@ -1594,8 +1594,8 @@ func reconcileSessionBeadsTracedWithNamedDemand( // Handle BEFORE heal/stability to avoid false crash detection — // a running session that leaves the desired set is not a crash. if !desired { - providerAlive, err := workerSessionTargetRunningWithConfig(cityPath, store, sp, cfg, session.ID) - if err != nil { + providerAlive, livenessErr := workerSessionTargetRunningWithConfig(cityPath, store, sp, cfg, session.ID) + if livenessErr != nil { providerAlive = false } // Run this before configured named-session preservation. A stale @@ -1620,6 +1620,21 @@ func reconcileSessionBeadsTracedWithNamedDemand( if template == "" { template = info.Template } + if livenessErr != nil { + // Fail CLOSED: providerAlive=false here is "observation + // unavailable", not "confirmed dead". Rolling back this + // pending-create bead when its session may still be alive on a + // transient tmux/store blip would orphan it (#3872-family). The + // level-triggered loop re-observes next tick; skip the + // destructive rollback for now. + fmt.Fprintf(stderr, "session reconciler: skipping pending-create rollback of '%s': liveness observation failed: %v\n", name, livenessErr) //nolint:errcheck + if trace != nil { + trace.RecordDecision(TraceSiteReconcilerPendingCreate, TraceReasonCode("pending_create_lease_expired"), TraceOutcomeSkippedLivenessError, template, name, traceRecordPayload{ + "liveness_error": livenessErr.Error(), + }) + } + continue + } peek := cachedSessionPeek(cityPath, store, sp, cfg, session.ID, nil) rateLimitHit, rlBatch, rateLimitErr := checkRateLimitStability(session, cfg, providerAlive, dt, sessFront, clk, peek) if rateLimitHit || rateLimitErr != nil { @@ -1716,6 +1731,21 @@ func reconcileSessionBeadsTracedWithNamedDemand( continue } if !providerAlive { + if livenessErr != nil { + // Fail CLOSED: providerAlive=false here is "observation + // unavailable", not "confirmed dead". Closing this + // failed-create bead when its session may still be alive on a + // transient tmux/store blip would orphan it (#3872-family). The + // level-triggered loop re-observes next tick; skip the + // destructive close for now. + fmt.Fprintf(stderr, "session reconciler: skipping failed-create close of '%s': liveness observation failed: %v\n", name, livenessErr) //nolint:errcheck + if trace != nil { + trace.RecordDecision(TraceSiteReconcilerCloseFailedCreate, TraceReasonCode(sessionpkg.StateFailedCreate), TraceOutcomeSkippedLivenessError, template, name, traceRecordPayload{ + "liveness_error": livenessErr.Error(), + }) + } + continue + } if trace != nil { trace.RecordDecision(TraceSiteReconcilerCloseFailedCreate, TraceReasonCode(sessionpkg.StateFailedCreate), TraceOutcomeClosed, template, name, nil) } @@ -1895,6 +1925,21 @@ func reconcileSessionBeadsTracedWithNamedDemand( if template == "" { template = infoPostHeal.Template } + if livenessErr != nil { + // Fail CLOSED: providerAlive=false here is "observation + // unavailable", not "confirmed dead". Finalizing (closing) + // this drain-acked session when its runtime may still be + // alive on a transient tmux/store blip would orphan it + // (#3872-family). The level-triggered loop re-observes next + // tick; skip the destructive finalize for now. + fmt.Fprintf(stderr, "session reconciler: skipping drain-ack finalize of '%s': liveness observation failed: %v\n", name, livenessErr) //nolint:errcheck + if trace != nil { + trace.RecordDecision(TraceSiteReconcilerDrainAck, TraceReasonOrphaned, TraceOutcomeSkippedLivenessError, template, name, traceRecordPayload{ + "liveness_error": livenessErr.Error(), + }) + } + continue + } result := finalizeDrainAckStoppedSession( cityPath, cfg, store, rigStores, session, infoByID[session.ID], template, true, dops, dt, clk, rec, stderr, @@ -1994,6 +2039,25 @@ func reconcileSessionBeadsTracedWithNamedDemand( if template == "" { template = infoPostHeal.Template } + if livenessErr != nil { + // Fail CLOSED: the runtime liveness probe errored, so + // providerAlive=false is "observation unavailable", not + // "confirmed dead". Closing here would orphan a bead whose + // session may still be alive on a transient tmux/store blip + // (#3872-family). The level-triggered loop re-observes next + // tick; skip the destructive close for now. (The plain Ctrl-C + // drain path above is unaffected — it only runs when + // providerAlive. The other !providerAlive destructive paths in + // this block — pending-create rollback, failed-create close, and + // drain-ack finalize — carry the same fail-closed guard.) + fmt.Fprintf(stderr, "session reconciler: skipping close of '%s': liveness observation failed: %v\n", name, livenessErr) //nolint:errcheck + if trace != nil { + trace.RecordDecision(TraceSiteReconcilerCloseOrphan, TraceReasonCode(reason), TraceOutcomeSkippedLivenessError, template, name, traceRecordPayload{ + "liveness_error": livenessErr.Error(), + }) + } + continue + } if trace != nil { trace.RecordDecision(TraceSiteReconcilerCloseOrphan, TraceReasonCode(reason), TraceOutcomeClosed, template, name, nil) } diff --git a/cmd/gc/session_reconciler_trace_test.go b/cmd/gc/session_reconciler_trace_test.go index 55d855a3ee..6d4d1874a0 100644 --- a/cmd/gc/session_reconciler_trace_test.go +++ b/cmd/gc/session_reconciler_trace_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -617,6 +618,107 @@ func TestSessionReconcilePhaseTraceUsesDistinctSites(t *testing.T) { } } +// livenessGetErrStore forces Get(target) to fail so the reconciler's runtime +// liveness probe returns an observation error (livenessErr != nil) for that one +// session, without disturbing any other store access. The reconcile loop body +// never re-reads the session bead through the store (it works off the passed-in +// slice and the mid-tick snapshot), so this affects only the liveness probe. +type livenessGetErrStore struct { + beads.Store + target string + err error +} + +func (s livenessGetErrStore) Get(id string) (beads.Bead, error) { + if id == s.target { + return beads.Bead{}, s.err + } + return s.Store.Get(id) +} + +// TestReconcileOrphanCloseFailsClosedOnLivenessError proves the S16 fail-closed +// gate fires end to end in the running reconciler (F1). A healthy liveness +// observation closes the undesired, dead orphan (baseline). But when the +// liveness probe errors — providerAlive=false then means "observation +// unavailable", not "confirmed dead" — the destructive orphan CLOSE is skipped +// this tick and the bead is kept open for re-observation, rather than orphaning +// a session that may still be alive on a transient blip (#3872-family). The +// only variable between the two runs is the liveness observation error, so the +// close→keep-open flip (plus the guard's stderr line) isolates the guard. The +// three sibling !providerAlive destructive paths (pending-create rollback, +// failed-create close, drain-ack finalize) carry the identical guard added in +// this PR. +func TestReconcileOrphanCloseFailsClosedOnLivenessError(t *testing.T) { + run := func(t *testing.T, injectLivenessErr bool) (status, stderr string) { + t.Helper() + env := newReconcilerTestEnv() + env.cfg = &config.City{} + // An asleep, undesired session with a dead runtime is the plain + // orphan-close case: createSessionBead defaults state=asleep and an empty + // desiredState makes it undesired. + session := env.createSessionBead("worker", "worker") + + store := env.store + if injectLivenessErr { + // Fail the liveness probe's read of just this session. With sp=nil, + // handle construction surfaces the failure as an observation error — + // the same class a transient tmux/store blip produces at runtime. + store = livenessGetErrStore{ + Store: env.store, + target: session.ID, + err: errors.New("boom: transient store failure"), + } + } + + var stderrBuf bytes.Buffer + reconcileSessionBeads( + context.Background(), + []beads.Bead{session}, + nil, // desiredState — empty ⇒ orphan + nil, // configuredNames + env.cfg, + nil, // sp — nil ⇒ dead runtime; with the wrapped store the probe errors + store, // store + nil, // dops + nil, // assignedWorkBeads + nil, // readyWaitSet + newDrainTracker(), + nil, // poolDesired + false, // storeQueryPartial + nil, // workSet + "", // cityName + nil, // idleTracker + env.clk, + events.Discard, + 0, 0, + io.Discard, &stderrBuf, + ) + + got, err := env.store.Get(session.ID) + if err != nil { + t.Fatalf("Get(%s): %v", session.ID, err) + } + return got.Status, stderrBuf.String() + } + + t.Run("healthy liveness closes orphan (baseline)", func(t *testing.T) { + status, _ := run(t, false) + if status != "closed" { + t.Fatalf("baseline orphan close: status = %q, want closed (the close path must be reachable for the guard to matter)", status) + } + }) + + t.Run("liveness error skips the close (fail closed)", func(t *testing.T) { + status, stderr := run(t, true) + if status == "closed" { + t.Fatalf("orphan bead was closed despite a liveness observation error; want kept open (fail closed)") + } + if !strings.Contains(stderr, "skipping close of 'worker': liveness observation failed") { + t.Fatalf("expected the fail-closed guard's stderr line, got %q", stderr) + } + }) +} + func TestTraceFlushAfterEndOnlyPersistsPostEndRecords(t *testing.T) { cityDir := t.TempDir() tracer := newSessionReconcilerTracer(cityDir, "trace-town", io.Discard) diff --git a/cmd/gc/session_reconciler_trace_types.go b/cmd/gc/session_reconciler_trace_types.go index 3db5f8f25c..e6c955e237 100644 --- a/cmd/gc/session_reconciler_trace_types.go +++ b/cmd/gc/session_reconciler_trace_types.go @@ -270,6 +270,15 @@ const ( TraceOutcomeDeferredUserHold TraceOutcomeCode = "deferred_user_hold" TraceOutcomeDeferredQuarantine TraceOutcomeCode = "deferred_quarantine" TraceOutcomeDeferredBusy TraceOutcomeCode = "deferred_busy" + + // TraceOutcomeSkippedLivenessError marks a destructive reconciler action + // (pending-create rollback, failed-create close, drain-ack finalize, or + // orphan close) skipped this tick because the runtime liveness probe + // returned an observation error. providerAlive=false then means + // "observation unavailable", not "confirmed dead", so the level-triggered + // loop fails closed and re-observes next tick rather than orphaning a + // possibly-live session (#3872-family). + TraceOutcomeSkippedLivenessError TraceOutcomeCode = "skipped_liveness_error" ) type TraceCompletionStatus string diff --git a/internal/dispatch/control.go b/internal/dispatch/control.go index d3225c70c4..fcd10b8a57 100644 --- a/internal/dispatch/control.go +++ b/internal/dispatch/control.go @@ -139,7 +139,7 @@ func processAttemptControl(store beads.Store, bead beads.Bead, opts ProcessOptio if err != nil { return ControlResult{}, err } - attemptLog, err := appendAttemptLogValue(bead.Metadata[beadmeta.AttemptLogMetadataKey], attemptNum, eval.logOutcome, eval.logDetail) + attemptLog, err := appendAttemptLogValue(bead.Metadata[beadmeta.AttemptLogMetadataKey], attemptNum, eval.logOutcome, eval.logDetail, opts.tracef) if err != nil { return ControlResult{}, fmt.Errorf("%s: recording attempt log: %w", bead.ID, err) } @@ -343,18 +343,26 @@ func markControllerSpawnError(store beads.Store, beadID string, err error, opts if IsTransientControllerError(err) && !isPartialAttemptAttachError(err) { metadata[beadmeta.ControllerErrorClassMetadataKey] = beadmeta.FailureClassTransient metadata[beadmeta.ControllerRetryableMetadataKey] = "true" - _ = store.SetMetadataBatch(beadID, metadata) + if writeErr := store.SetMetadataBatch(beadID, metadata); writeErr != nil { + opts.tracef("controller-spawn-error bead=%s recording transient failure metadata failed err=%v", beadID, writeErr) + } return true } metadata[beadmeta.ControllerErrorClassMetadataKey] = beadmeta.FailureClassHard metadata[beadmeta.ControllerRetryableMetadataKey] = "" metadata[beadmeta.FinalDispositionMetadataKey] = beadmeta.DispositionControllerError - _ = store.SetMetadataBatch(beadID, metadata) - _ = setOutcomeAndClose(store, beadID, beadmeta.OutcomeFail) + if writeErr := store.SetMetadataBatch(beadID, metadata); writeErr != nil { + opts.tracef("controller-spawn-error bead=%s recording hard failure metadata failed err=%v", beadID, writeErr) + } + if closeErr := setOutcomeAndClose(store, beadID, beadmeta.OutcomeFail); closeErr != nil { + opts.tracef("controller-spawn-error bead=%s closing failed bead failed err=%v", beadID, closeErr) + } // Reconcile any enclosing scope so a controller_error terminal closure // does not leave the scope body stalled. - _, _ = reconcileClosedScopeMemberWithOptions(store, beadID, opts) + if _, scopeErr := reconcileClosedScopeMemberWithOptions(store, beadID, opts); scopeErr != nil { + opts.tracef("controller-spawn-error bead=%s reconciling enclosing scope failed err=%v", beadID, scopeErr) + } return false } @@ -496,7 +504,7 @@ func spawnNextAttempt(ctx context.Context, store beads.Store, control beads.Bead // available, and only inherit the parent execution lane as a fallback. executionRoute := strings.TrimSpace(control.Metadata[beadmeta.ExecutionRoutedToMetadataKey]) executionRigContext := strings.TrimSpace(control.Metadata[beadmeta.ExecutionRigContextMetadataKey]) - routeCfg := loadAttemptRouteConfig(opts.CityPath) + routeCfg, _ := opts.routeConfig() for i := range recipe.Steps { if recipe.Steps[i].Metadata[beadmeta.KindMetadataKey] == beadmeta.KindSpec { continue @@ -990,15 +998,19 @@ func attemptRecipeStepNeedsScopeCheck(step formula.RecipeStep) bool { return !beadmeta.IsScopeCheckExemptKind(step.Metadata[beadmeta.KindMetadataKey]) } -func loadAttemptRouteConfig(cityPath string) *config.City { +// loadAttemptRouteConfigE loads the city.toml used for attempt-time routing. +// An empty cityPath yields (nil, nil) — routing legitimately runs metadata-only +// when no city config is present. A genuine parse failure is returned rather +// than swallowed so callers (via ProcessOptions.routeConfig) can surface it. +func loadAttemptRouteConfigE(cityPath string) (*config.City, error) { if strings.TrimSpace(cityPath) == "" { - return nil + return nil, nil } cfg, _, err := config.LoadWithIncludes(fsys.OSFS{}, filepath.Join(cityPath, "city.toml")) if err != nil { - return nil + return nil, fmt.Errorf("loading attempt-route config from %s: %w", cityPath, err) } - return cfg + return cfg, nil } func applyAttemptStepRoute(step *formula.RecipeStep, target string, cfg *config.City, store beads.Store) { @@ -1177,10 +1189,6 @@ func isAttemptMultiSessionTarget(target string, cfg *config.City) bool { return agentCfg != nil && agentCfg.SupportsInstanceExpansion() } -func beadUsesMetadataPoolRoute(bead beads.Bead, cityPath string) bool { - return beadUsesMetadataPoolRouteWithConfig(bead, loadAttemptRouteConfig(cityPath)) -} - func beadUsesMetadataPoolRouteWithConfig(bead beads.Bead, cfg *config.City) bool { if isAttemptMultiSessionTarget(routedAttemptTarget(bead), cfg) { return true @@ -1594,17 +1602,24 @@ func appendAttemptLog(store beads.Store, controlID string, attempt int, outcome, if err != nil { return err } - logJSON, err := appendAttemptLogValue(control.Metadata[beadmeta.AttemptLogMetadataKey], attempt, outcome, reason) + logJSON, err := appendAttemptLogValue(control.Metadata[beadmeta.AttemptLogMetadataKey], attempt, outcome, reason, nil) if err != nil { return err } return store.SetMetadata(controlID, beadmeta.AttemptLogMetadataKey, logJSON) } -func appendAttemptLogValue(existing string, attempt int, outcome, reason string) (string, error) { +func appendAttemptLogValue(existing string, attempt int, outcome, reason string, tracef func(string, ...any)) (string, error) { var log []map[string]string if existing != "" { - _ = json.Unmarshal([]byte(existing), &log) + if err := json.Unmarshal([]byte(existing), &log); err != nil { + // A corrupt audit history cannot be recovered, so we start fresh — + // but surface the reset instead of silently discarding the log. + if tracef != nil { + tracef("attempt-log corrupt, resetting history existing=%q err=%v", existing, err) + } + log = nil + } } entry := map[string]string{ @@ -1674,5 +1689,5 @@ func updateMetadataAndClose(store beads.Store, beadID string, metadata map[strin } // Note: listByWorkflowRoot, setOutcomeAndClose, propagateRetrySubjectMetadata, -// classifyRetryAttempt, retryPreservedAssignee, and runRalphCheck are defined -// in runtime.go, retry.go, and ralph.go respectively. +// classifyRetryAttempt, retryPreservedAssigneeWithConfig, and runRalphCheck are +// defined in runtime.go, retry.go, and ralph.go respectively. diff --git a/internal/dispatch/control_test.go b/internal/dispatch/control_test.go index 3462fee5ae..839ea114a4 100644 --- a/internal/dispatch/control_test.go +++ b/internal/dispatch/control_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "os" "path/filepath" "strconv" "strings" @@ -2756,6 +2757,94 @@ func TestAttemptLogJSONRoundTrips(t *testing.T) { } } +// TestAppendAttemptLogValueCorruptHistoryTracesReset proves the corrupt-log +// fix: a malformed existing gc.attempt_log is no longer silently discarded — it +// is traced and a valid fresh entry is written. +func TestAppendAttemptLogValueCorruptHistoryTracesReset(t *testing.T) { + t.Parallel() + var traced []string + tracef := func(format string, args ...any) { + traced = append(traced, fmt.Sprintf(format, args...)) + } + + out, err := appendAttemptLogValue("{not valid json", 3, "transient", "rate_limited", tracef) + if err != nil { + t.Fatalf("appendAttemptLogValue: %v", err) + } + if len(traced) != 1 || !strings.Contains(traced[0], "attempt-log corrupt") { + t.Fatalf("expected one corrupt-log trace, got %v", traced) + } + var log []map[string]string + if err := json.Unmarshal([]byte(out), &log); err != nil { + t.Fatalf("output not valid JSON: %v (raw=%q)", err, out) + } + if len(log) != 1 || log[0]["attempt"] != "3" { + t.Fatalf("expected fresh single-entry log, got %v", log) + } +} + +// TestAppendAttemptLogValueValidHistoryDoesNotTrace guards against noise: a +// well-formed history appends normally and never fires the corrupt-log trace. +func TestAppendAttemptLogValueValidHistoryDoesNotTrace(t *testing.T) { + t.Parallel() + traced := 0 + tracef := func(string, ...any) { traced++ } + + out, err := appendAttemptLogValue(`[{"attempt":"1","outcome":"transient","action":"retry"}]`, 2, "pass", "", tracef) + if err != nil { + t.Fatalf("appendAttemptLogValue: %v", err) + } + if traced != 0 { + t.Fatalf("valid history must not trace, got %d traces", traced) + } + var log []map[string]string + if err := json.Unmarshal([]byte(out), &log); err != nil { + t.Fatalf("output not valid JSON: %v", err) + } + if len(log) != 2 { + t.Fatalf("expected two entries, got %d", len(log)) + } +} + +// TestRouteConfigSurfacesLoadErrorOnce proves the swallowed city.toml parse +// error is now surfaced (returned + traced) and that the lazy cache parses at +// most once per invocation. +func TestRouteConfigSurfacesLoadErrorOnce(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "city.toml"), []byte("key = \"unterminated"), 0o644); err != nil { + t.Fatalf("write city.toml: %v", err) + } + traces := 0 + opts := ProcessOptions{ + CityPath: dir, + Tracef: func(string, ...any) { traces++ }, + routeCfg: &routeConfigCache{}, + } + + cfg, err := opts.routeConfig() + if err == nil { + t.Fatalf("expected the load error to be surfaced, got nil (cfg=%v)", cfg) + } + if _, err2 := opts.routeConfig(); err2 == nil { + t.Fatalf("expected the cached load error on the second call") + } + if traces != 1 { + t.Fatalf("expected exactly one trace across two cached calls, got %d", traces) + } +} + +// TestRouteConfigEmptyCityPathIsNilNoError confirms an absent city path stays a +// legitimate metadata-only route (nil cfg, nil error) — not an error. +func TestRouteConfigEmptyCityPathIsNilNoError(t *testing.T) { + t.Parallel() + opts := ProcessOptions{routeCfg: &routeConfigCache{}} + cfg, err := opts.routeConfig() + if err != nil || cfg != nil { + t.Fatalf("empty CityPath must yield (nil,nil), got cfg=%v err=%v", cfg, err) + } +} + // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- diff --git a/internal/dispatch/drain.go b/internal/dispatch/drain.go index 54ed710977..efefbe10fa 100644 --- a/internal/dispatch/drain.go +++ b/internal/dispatch/drain.go @@ -191,7 +191,11 @@ func expandDrain(store beads.Store, bead beads.Bead, opts ProcessOptions) (Contr return ControlResult{}, fmt.Errorf("%s: recording expanded drain: %w", bead.ID, err) } if len(manifest.Rows) == 0 { - return completeDrain(store, mustReloadDrain(store, bead), opts) + reloaded, err := reloadDrain(store, bead) + if err != nil { + return ControlResult{}, err + } + return completeDrain(store, reloaded, opts) } return ControlResult{Processed: true, Action: "drain-expanded", Created: totalCreated}, nil } @@ -1468,10 +1472,14 @@ func drainOnItemFailure(bead beads.Bead) string { return beadmeta.DrainOnItemFailureContinue } -func mustReloadDrain(store beads.Store, bead beads.Bead) beads.Bead { +// reloadDrain re-reads the drain control bead so completeDrain sees the freshly +// persisted post-expansion state. On a read error it returns the error rather +// than the stale pre-transition bead, so the caller can retry next tick instead +// of completing the drain against a stale snapshot. +func reloadDrain(store beads.Store, bead beads.Bead) (beads.Bead, error) { reloaded, err := store.Get(bead.ID) if err != nil { - return bead + return beads.Bead{}, fmt.Errorf("%s: reloading drain before completion: %w", bead.ID, err) } - return reloaded + return reloaded, nil } diff --git a/internal/dispatch/fanout.go b/internal/dispatch/fanout.go index 87082a6fb1..a785b74222 100644 --- a/internal/dispatch/fanout.go +++ b/internal/dispatch/fanout.go @@ -286,7 +286,7 @@ func routeFanoutFragmentSteps(fragment *formula.FragmentRecipe, control beads.Be } executionRoute := strings.TrimSpace(control.Metadata[beadmeta.ExecutionRoutedToMetadataKey]) executionRigContext := strings.TrimSpace(control.Metadata[beadmeta.ExecutionRigContextMetadataKey]) - routeCfg := loadAttemptRouteConfig(opts.CityPath) + routeCfg, _ := opts.routeConfig() for i := range fragment.Steps { step := &fragment.Steps[i] if step.Metadata[beadmeta.KindMetadataKey] == beadmeta.KindSpec { diff --git a/internal/dispatch/ralph.go b/internal/dispatch/ralph.go index 74972603eb..43828d2e64 100644 --- a/internal/dispatch/ralph.go +++ b/internal/dispatch/ralph.go @@ -440,7 +440,7 @@ func appendRalphRetry(store beads.Store, logicalID string, prevSubject, prevChec } return existing, nil } - cfg := loadAttemptRouteConfig(opts.CityPath) + cfg, _ := opts.routeConfig() if molecule.IsGraphApplyEnabled() { if applier, ok := beads.GraphApplyFor(store); ok { return appendRalphRetryViaGraphApply(store, applier, logicalID, prevSubject, prevCheck, attemptSet, oldAttempt, nextAttempt, oldScopeRef, newScopeRef, cfg, opts) @@ -744,10 +744,6 @@ func buildRalphRetryGraphNode(old beads.Bead, logicalID, oldScopeRef, newScopeRe } } -func retryPreservedAssignee(bead beads.Bead, cityPath string) string { - return retryPreservedAssigneeWithConfig(bead, loadAttemptRouteConfig(cityPath)) -} - func retryPreservedAssigneeWithConfig(bead beads.Bead, cfg *config.City) string { if bead.Assignee == "" { return "" diff --git a/internal/dispatch/retry.go b/internal/dispatch/retry.go index ca7a3bbc02..5e71bb40dd 100644 --- a/internal/dispatch/retry.go +++ b/internal/dispatch/retry.go @@ -11,6 +11,7 @@ import ( "github.com/gastownhall/gascity/internal/beadmeta" "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/pathutil" ) @@ -162,7 +163,8 @@ func processRetryEval(store beads.Store, bead beads.Bead, opts ProcessOptions) ( return ControlResult{}, fmt.Errorf("%s: unsupported gc.retry_state %q", bead.ID, bead.Metadata[beadmeta.RetryStateMetadataKey]) } - if beadUsesMetadataPoolRoute(subject, opts.CityPath) { + routeCfg, _ := opts.routeConfig() + if beadUsesMetadataPoolRouteWithConfig(subject, routeCfg) { if opts.RecycleSession == nil { return ControlResult{}, fmt.Errorf("%s: pooled retry subject %s requires RecycleSession callback", bead.ID, subject.ID) } @@ -180,7 +182,7 @@ func processRetryEval(store beads.Store, bead beads.Bead, opts ProcessOptions) ( } if bead.Metadata[beadmeta.RetryStateMetadataKey] != beadmeta.SpawnStateSpawned { - if err := appendRetryAttempt(store, logicalID, subject, bead, nextAttempt, opts.CityPath); err != nil { + if err := appendRetryAttempt(store, logicalID, subject, bead, nextAttempt, routeCfg); err != nil { if controllerSpawnBoundaryPending(store, bead.ID, err, opts) { return ControlResult{}, ErrControlPending } @@ -491,7 +493,7 @@ func propagateRetrySubjectMetadata(store beads.Store, logicalID string, subject return store.SetMetadataBatch(logicalID, batch) } -func appendRetryAttempt(store beads.Store, logicalID string, prevRun, prevEval beads.Bead, nextAttempt int, cityPath string) error { +func appendRetryAttempt(store beads.Store, logicalID string, prevRun, prevEval beads.Bead, nextAttempt int, routeCfg *config.City) error { oldAttempt, err := strconv.Atoi(prevRun.Metadata[beadmeta.AttemptMetadataKey]) if err != nil || oldAttempt < 1 { return fmt.Errorf("%s: invalid gc.attempt %q", prevRun.ID, prevRun.Metadata[beadmeta.AttemptMetadataKey]) @@ -522,7 +524,7 @@ func appendRetryAttempt(store beads.Store, logicalID string, prevRun, prevEval b } if nextRun.ID == "" { - nextRun, err = store.Create(retryAttemptBead(prevRun, logicalID, runRef, nextAttempt, cityPath)) + nextRun, err = store.Create(retryAttemptBead(prevRun, logicalID, runRef, nextAttempt, routeCfg)) if err != nil { return fmt.Errorf("creating retry run bead: %w", err) } @@ -543,10 +545,10 @@ func appendRetryAttempt(store beads.Store, logicalID string, prevRun, prevEval b return nil } -func retryAttemptBead(prev beads.Bead, logicalID, stepRef string, attempt int, cityPath string) beads.Bead { +func retryAttemptBead(prev beads.Bead, logicalID, stepRef string, attempt int, routeCfg *config.City) beads.Bead { meta := cloneMetadata(prev.Metadata) clearRetryEphemera(meta) - assignee := retryPreservedAssignee(prev, cityPath) + assignee := retryPreservedAssigneeWithConfig(prev, routeCfg) if assignee == "" { clearSessionAffinityMetadata(meta) } diff --git a/internal/dispatch/runtime.go b/internal/dispatch/runtime.go index 02160af29b..8664c6b7fd 100644 --- a/internal/dispatch/runtime.go +++ b/internal/dispatch/runtime.go @@ -7,11 +7,13 @@ import ( "os" "sort" "strings" + "sync" "time" "unicode/utf8" "github.com/gastownhall/gascity/internal/beadmeta" "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/formula" "github.com/gastownhall/gascity/internal/molecule" "github.com/gastownhall/gascity/internal/sourceworkflow" @@ -71,6 +73,40 @@ type ProcessOptions struct { // the primary store, exactly matching the pre-seam single-store behavior. MemberStores []beads.Store Tracef func(format string, args ...any) + + // routeCfg lazily caches the city.toml used for attempt-time routing + // decisions so a single ProcessControl invocation parses it at most once + // (previously it was re-parsed per processed bead via loadAttemptRouteConfig, + // beadUsesMetadataPoolRoute, and retryPreservedAssignee). It is a pointer so + // the cache is shared across the by-value opts copies handed to sub-steps. + routeCfg *routeConfigCache +} + +// routeConfigCache memoizes a single attempt-route config load (and its error) +// for the lifetime of one ProcessControl invocation. +type routeConfigCache struct { + once sync.Once + cfg *config.City + err error +} + +// routeConfig returns the attempt-route config for this invocation, loading it +// at most once. The load error is no longer swallowed: it is memoized, traced +// once, and returned to callers so routing decisions can observe it. Callers +// that bypass ProcessControl (direct-called sub-steps in tests) get a fresh +// uncached load, preserving their prior behavior. +func (opts ProcessOptions) routeConfig() (*config.City, error) { + cache := opts.routeCfg + if cache == nil { + return loadAttemptRouteConfigE(opts.CityPath) + } + cache.once.Do(func() { + cache.cfg, cache.err = loadAttemptRouteConfigE(opts.CityPath) + if cache.err != nil { + opts.tracef("process-control route-config load failed city_path=%q err=%v (routing falls back to metadata-only)", opts.CityPath, cache.err) + } + }) + return cache.cfg, cache.err } var ( @@ -109,6 +145,12 @@ func ProcessControl(store beads.Store, bead beads.Bead, opts ProcessOptions) (Co if store == nil { return ControlResult{}, fmt.Errorf("store is nil") } + // Resolve the attempt-route config once per invocation. opts is copied by + // value into every sub-step, so a shared pointer cache collapses the former + // up-to-N-per-cycle city.toml parses to one lazy load. + if opts.routeCfg == nil { + opts.routeCfg = &routeConfigCache{} + } if bead.Status != "open" { // A control bead that is not open — typically stuck at in_progress // after a rogue `bd update --status in_progress` from a worker — diff --git a/internal/sling/sling_attachment.go b/internal/sling/sling_attachment.go index 338707a498..ec3dc81917 100644 --- a/internal/sling/sling_attachment.go +++ b/internal/sling/sling_attachment.go @@ -323,45 +323,62 @@ func checkBatchNoMoleculeChildren(q BeadChildQuerier, open []beads.Bead, store b // needsConvoyRecovery reports whether an already-routed bead should re-enter // finalize to repair missing or closed auto-convoy membership. -func needsConvoyRecovery(q BeadQuerier, b beads.Bead, deps SlingDeps, opts BeadCheckOptions) bool { +// +// It fails CLOSED on a store error: rather than reporting "recovery needed" +// (which re-runs finalize and mints a duplicate auto-convoy under a transient +// store hiccup, #2987), it returns the error so callers can treat the convoy +// as already present. A read error is never evidence that recovery is needed. +func needsConvoyRecovery(q BeadQuerier, b beads.Bead, deps SlingDeps, opts BeadCheckOptions) (bool, error) { if opts.NoConvoy { - return false + return false, nil } - if hasLiveTrackingConvoy(deps.Store, b.ID) { - return false + live, err := hasLiveTrackingConvoy(deps.Store, b.ID) + if err != nil { + return false, err + } + if live { + return false, nil } parentID := strings.TrimSpace(b.ParentID) if parentID == "" { - return true + return true, nil } if q == nil { - return false + return false, nil } parent, err := q.Get(parentID) if err != nil { - return true + if errors.Is(err, beads.ErrNotFound) { + // A genuinely deleted parent is not a transient store hiccup: the + // routed child is orphaned, so finalize must re-run to recreate its + // missing auto-convoy. Return recovery-needed rather than failing + // closed. Only ambiguous/transient store errors fail closed below + // (assuming the convoy already exists, #2987). + return true, nil + } + return false, fmt.Errorf("reading parent %s for convoy recovery of %s: %w", parentID, b.ID, err) } if parent.Type == "convoy" { - return convoycore.IsTerminalStatus(parent.Status) + return convoycore.IsTerminalStatus(parent.Status), nil } if sourceworkflow.IsWorkflowRoot(parent) { - return false + return false, nil } // Ordinary parent beads do not own the routing lifecycle. A routed child // without a live tracking convoy needs finalize to run again so the missing // auto-convoy can be recreated; finalize is idempotent for an already-routed // bead because CheckBeadState preserves the routed metadata and only repairs // the missing tracking attachment. - return true + return true, nil } -func hasLiveTrackingConvoy(store beads.Store, itemID string) bool { +func hasLiveTrackingConvoy(store beads.Store, itemID string) (bool, error) { if store == nil { - return false + return false, nil } convoys, err := convoycore.TrackingConvoysForItem(store, itemID) if err != nil { - return false + return false, fmt.Errorf("listing tracking convoys for %s: %w", itemID, err) } for _, convoy := range convoys { // These are convoys by construction, so the convoy type's Ready @@ -371,10 +388,31 @@ func hasLiveTrackingConvoy(store beads.Store, itemID string) bool { continue } if !convoycore.IsTerminalStatus(convoy.Status) { - return true + return true, nil + } + } + return false, nil +} + +// resolveConvoyRecovery maps needsConvoyRecovery onto a BeadCheckResult for an +// already-routed bead: an empty result when finalize must re-run to recreate a +// missing auto-convoy, or Idempotent otherwise. On a store error it fails +// CLOSED — assuming the convoy already exists rather than minting a duplicate +// (#2987) — and surfaces the error as a warning instead of swallowing it. +func resolveConvoyRecovery(q BeadQuerier, b beads.Bead, deps SlingDeps, opts BeadCheckOptions, beadID string) BeadCheckResult { + needRecovery, err := needsConvoyRecovery(q, b, deps, opts) + if err != nil { + return BeadCheckResult{ + Idempotent: true, + Warnings: []string{fmt.Sprintf("warning: bead %s convoy-recovery check failed, assuming convoy exists: %v", beadID, err)}, } } - return false + if needRecovery { + // Prior sling set gc.routed_to but left no convoy — let finalize + // re-run to create it and poke the controller. + return BeadCheckResult{} + } + return BeadCheckResult{Idempotent: true} } // CheckBeadState checks whether a bead is already routed and returns a @@ -401,12 +439,7 @@ func CheckBeadStateWithOptions(q BeadQuerier, beadID string, a config.Agent, dep target := a.QualifiedName() if strings.TrimSpace(b.Metadata[beadmeta.RoutedToMetadataKey]) == target { if b.Assignee == "" || b.Assignee == target { - if needsConvoyRecovery(q, b, deps, opts) { - // Prior sling set gc.routed_to but left no convoy — let - // finalize re-run to create it and poke the controller. - return BeadCheckResult{} - } - return BeadCheckResult{Idempotent: true} + return resolveConvoyRecovery(q, b, deps, opts, beadID) } return BeadCheckResult{ Warnings: []string{fmt.Sprintf("warning: bead %s routed to %q but assigned to %q", beadID, target, b.Assignee)}, @@ -416,10 +449,7 @@ func CheckBeadStateWithOptions(q BeadQuerier, beadID string, a config.Agent, dep isMulti := agentutil.IsMultiSessionAgent(&a) if !isMulti { if b.Assignee == target { - if needsConvoyRecovery(q, b, deps, opts) { - return BeadCheckResult{} - } - return BeadCheckResult{Idempotent: true} + return resolveConvoyRecovery(q, b, deps, opts, beadID) } return BeadCheckResult{Warnings: routedStateWarnings(b, beadID)} } @@ -428,10 +458,7 @@ func CheckBeadStateWithOptions(q BeadQuerier, beadID string, a config.Agent, dep poolLabel := "pool:" + target for _, l := range b.Labels { if l == poolLabel { - if needsConvoyRecovery(q, b, deps, opts) { - return BeadCheckResult{} - } - return BeadCheckResult{Idempotent: true} + return resolveConvoyRecovery(q, b, deps, opts, beadID) } } } diff --git a/internal/sling/sling_test.go b/internal/sling/sling_test.go index 158028905a..16be35ca70 100644 --- a/internal/sling/sling_test.go +++ b/internal/sling/sling_test.go @@ -470,6 +470,112 @@ func TestCheckBeadStateRoutedWithClosedConvoyIsNotIdempotent(t *testing.T) { } } +// depListErrStore wraps a real store but forces DepList to fail, simulating a +// transient store hiccup during the tracking-convoy lookup (#2987). +type depListErrStore struct { + beads.Store + err error +} + +func (s depListErrStore) DepList(string, string) ([]beads.Dep, error) { + return nil, s.err +} + +// TestCheckBeadStateConvoyLookupErrorFailsClosed proves the fail-closed fix: +// when the tracking-convoy lookup errors (transient store failure), the routed +// bead is reported Idempotent with a surfaced warning instead of re-running +// finalize and minting a duplicate auto-convoy. Without the fix, the same +// setup returns Idempotent=false (convoy recovery), the #2987 silent-duplicate +// vector. +func TestCheckBeadStateConvoyLookupErrorFailsClosed(t *testing.T) { + backing := beads.NewMemStore() + bead, err := backing.Create(beads.Bead{ + Title: "route me", + Type: "task", + Status: "open", + Metadata: map[string]string{"gc.routed_to": "mayor"}, + }) + if err != nil { + t.Fatalf("store.Create(): %v", err) + } + + store := depListErrStore{Store: backing, err: errors.New("boom: store unavailable")} + + result := CheckBeadState(store, bead.ID, config.Agent{Name: "mayor"}, SlingDeps{Store: store}) + + if !result.Idempotent { + t.Fatalf("expected Idempotent=true (fail closed) on convoy-lookup error, got %+v", result) + } + if len(result.Warnings) == 0 { + t.Fatalf("expected a surfaced warning on convoy-lookup error, got %+v", result) + } +} + +// parentGetErrStore forces q.Get(parentID) to fail with a chosen error while +// serving every other bead from the backing store, isolating the parent-read +// error path in needsConvoyRecovery. +type parentGetErrStore struct { + beads.Store + parentID string + err error +} + +func (s parentGetErrStore) Get(id string) (beads.Bead, error) { + if id == s.parentID { + return beads.Bead{}, s.err + } + return s.Store.Get(id) +} + +// TestNeedsConvoyRecoveryDistinguishesDeletedParent proves the F3 fix: a routed +// child whose parent is genuinely deleted (ErrNotFound) still needs finalize to +// re-run (Idempotent=false), because a persistently-missing parent is not a +// transient hiccup. A transient parent-read error, by contrast, fails closed +// (Idempotent=true + warning) so a store blip never mints a duplicate +// auto-convoy (#2987). +func TestNeedsConvoyRecoveryDistinguishesDeletedParent(t *testing.T) { + newRoutedChild := func(t *testing.T, store beads.Store, parentID string) string { + t.Helper() + bead, err := store.Create(beads.Bead{ + Title: "routed child", + Type: "task", + Status: "open", + ParentID: parentID, + Metadata: map[string]string{"gc.routed_to": "mayor"}, + }) + if err != nil { + t.Fatalf("store.Create(): %v", err) + } + return bead.ID + } + + t.Run("deleted parent triggers recovery", func(t *testing.T) { + store := beads.NewMemStore() + beadID := newRoutedChild(t, store, "gcg-deleted-parent") + + result := CheckBeadState(store, beadID, config.Agent{Name: "mayor"}, SlingDeps{Store: store}) + + if result.Idempotent { + t.Fatalf("expected Idempotent=false (recovery needed) for a routed child with a deleted parent, got %+v", result) + } + }) + + t.Run("transient parent error fails closed", func(t *testing.T) { + backing := beads.NewMemStore() + beadID := newRoutedChild(t, backing, "gcg-parent") + store := parentGetErrStore{Store: backing, parentID: "gcg-parent", err: errors.New("boom: store unavailable")} + + result := CheckBeadState(store, beadID, config.Agent{Name: "mayor"}, SlingDeps{Store: store}) + + if !result.Idempotent { + t.Fatalf("expected Idempotent=true (fail closed) on a transient parent-read error, got %+v", result) + } + if len(result.Warnings) == 0 { + t.Fatalf("expected a surfaced warning on transient parent-read error, got %+v", result) + } + }) +} + func TestCheckBeadStateRoutedWithWorkflowParentIsIdempotent(t *testing.T) { tests := []struct { name string From fdf0847cb33472d0bb252b03af683f25038eb37b Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 8 Jul 2026 20:13:57 -0700 Subject: [PATCH 019/225] refactor(beadmeta): MoleculeFailedMetadataKey constant, retire bare "molecule_failed" literals (#4048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Extracts the bare `"molecule_failed"` metadata-key string into a single compiler-checked `beadmeta.MoleculeFailedMetadataKey` constant and routes the **9 production map-key / composite-literal-key sites** through it: - `internal/molecule/molecule.go` — `findExistingAttach`, `existingAttachIDMapping`, `markFailed` - `internal/dispatch/control.go` — `failedAttemptAttachRootID` ListQuery, `isFailedPartialMolecule` - `internal/dispatch/drain.go` — `ensureDrainItemRoot`, `closeFailedDrainItemRoots` - `internal/sling/sling.go` — `closeFailedGraphV2RootsByKey` - `cmd/gc/cmd_formula.go` — `closeFormulaCookFailedGraphV2Roots` **Byte-identical on-store value.** The constant equals the old literal, so no bead round-trips differently. Error-message prose (`molecule.go` "is marked molecule_failed") and the test-file literals are intentionally left as string literals — they are the wire-string drift guards proving the constant emits the identical value. Placed in the existing non-`gc.` "dispatch metadata keys" const block (alongside `MoleculeIDMetadataKey`), **not** in `KnownMetadataKeys` — that block's drift guard only covers the `gc.` namespace, exactly like the sibling `molecule_id` key. ## Scope This is the smallest, lowest-risk slice of a larger cleanup that routes business logic through typed domain objects / confined codecs instead of cracking raw beads inline. Deliberately **constant-only**: no `molecule.State` typed view was added, because every reader of `molecule_failed` is the dispatcher/substrate (where the bead legitimately *is* the domain object). ## Verification - `go build ./...`, `go vet ./...` clean - `go test ./internal/beadmeta ./internal/molecule ./internal/dispatch ./internal/sling` green - `go test ./cmd/gc -run 'Formula|Molecule|Drain'` green - `rg '"molecule_failed"'` confirms zero remaining production map-key literals - TDD: pinned-value test added first (red on undefined constant), then constant (green) Part of the raw-bead-leak cleanup epic. Refs `ga-vpemze`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/gc/cmd_formula.go | 2 +- internal/beadmeta/keys.go | 6 ++++++ internal/beadmeta/keys_test.go | 1 + internal/dispatch/control.go | 4 ++-- internal/dispatch/drain.go | 4 ++-- internal/molecule/molecule.go | 10 +++++----- internal/sling/sling.go | 2 +- 7 files changed, 18 insertions(+), 11 deletions(-) diff --git a/cmd/gc/cmd_formula.go b/cmd/gc/cmd_formula.go index c3e6138c8b..ac8c849f2a 100644 --- a/cmd/gc/cmd_formula.go +++ b/cmd/gc/cmd_formula.go @@ -950,7 +950,7 @@ func closeFormulaCookFailedGraphV2Roots(store beads.Store, recipe *formula.Recip return fmt.Errorf("looking up failed formulas v2 roots for key %s: %w", key, err) } for _, root := range matches { - if root.Status == "closed" || root.Metadata["molecule_failed"] != "true" { + if root.Status == "closed" || root.Metadata[beadmeta.MoleculeFailedMetadataKey] != "true" { continue } if _, err := sourceworkflow.CloseWorkflowSubtree(store, root.ID); err != nil { diff --git a/internal/beadmeta/keys.go b/internal/beadmeta/keys.go index 76672bfdbb..a196f34d90 100644 --- a/internal/beadmeta/keys.go +++ b/internal/beadmeta/keys.go @@ -253,6 +253,12 @@ const ( // MoleculeIDMetadataKey links a poured/wisp work bead to its molecule root. MoleculeIDMetadataKey = "molecule_id" + // MoleculeFailedMetadataKey marks the beads of a partially-instantiated + // molecule as failed (value "true"). Written best-effort by + // internal/molecule markFailed on instantiation error paths; read by + // dispatch/sling/cmd/gc to skip or close failed roots. + MoleculeFailedMetadataKey = "molecule_failed" + // MergeStrategyMetadataKey records the merge strategy chosen for a slung bead. MergeStrategyMetadataKey = "merge_strategy" ) diff --git a/internal/beadmeta/keys_test.go b/internal/beadmeta/keys_test.go index 4ade9c065d..e703fcc699 100644 --- a/internal/beadmeta/keys_test.go +++ b/internal/beadmeta/keys_test.go @@ -64,6 +64,7 @@ func TestPinnedValues(t *testing.T) { FormulaVarPrefix: "gc.var.", Namespace: "gc.", OptionMetadataPrefix: "opt_", + MoleculeFailedMetadataKey: "molecule_failed", } for got, want := range pinned { if got != want { diff --git a/internal/dispatch/control.go b/internal/dispatch/control.go index fcd10b8a57..1ebce0cb79 100644 --- a/internal/dispatch/control.go +++ b/internal/dispatch/control.go @@ -597,7 +597,7 @@ func failedAttemptAttachRootID(store beads.Store, control beads.Bead, attemptNum Metadata: map[string]string{ beadmeta.IdempotencyKeyMetadataKey: fmt.Sprintf("%s:attempt:%d", control.ID, attemptNum), beadmeta.RootBeadIDMetadataKey: rootID, - "molecule_failed": "true", + beadmeta.MoleculeFailedMetadataKey: "true", }, }) if err != nil { @@ -1377,7 +1377,7 @@ func recipeStepRef(step formula.RecipeStep) string { } func isFailedPartialMolecule(bead beads.Bead) bool { - return strings.TrimSpace(bead.Metadata["molecule_failed"]) == "true" + return strings.TrimSpace(bead.Metadata[beadmeta.MoleculeFailedMetadataKey]) == "true" } // findLatestAttempt finds the most recent attempt/iteration child of a control diff --git a/internal/dispatch/drain.go b/internal/dispatch/drain.go index efefbe10fa..a999964196 100644 --- a/internal/dispatch/drain.go +++ b/internal/dispatch/drain.go @@ -1016,7 +1016,7 @@ func ensureDrainItemRoot(store beads.Store, control, unit, member beads.Bead, co return "", false, fmt.Errorf("%s: looking up item root %s: %w", control.ID, row.ItemRootKey, err) } for _, candidate := range existing { - if candidate.Metadata["molecule_failed"] == "true" { + if candidate.Metadata[beadmeta.MoleculeFailedMetadataKey] == "true" { continue } return candidate.ID, false, nil @@ -1129,7 +1129,7 @@ func closeFailedDrainItemRoots(store beads.Store, controlID, itemRootKey string) return fmt.Errorf("%s: looking up failed drain item roots for key %s: %w", controlID, itemRootKey, err) } for _, root := range matches { - if root.Status == "closed" || root.Metadata["molecule_failed"] != "true" { + if root.Status == "closed" || root.Metadata[beadmeta.MoleculeFailedMetadataKey] != "true" { continue } if _, err := sourceworkflow.CloseWorkflowSubtree(store, root.ID); err != nil { diff --git a/internal/molecule/molecule.go b/internal/molecule/molecule.go index 7295737ee9..c81d89829b 100644 --- a/internal/molecule/molecule.go +++ b/internal/molecule/molecule.go @@ -340,7 +340,7 @@ func findExistingAttach(store beads.Store, recipe *formula.Recipe, rootBeadID, a if b.Metadata[beadmeta.RootBeadIDMetadataKey] != rootBeadID { continue } - if b.Metadata["molecule_failed"] == "true" { + if b.Metadata[beadmeta.MoleculeFailedMetadataKey] == "true" { return nil, fmt.Errorf("existing attach root %s for idempotency key %q is marked molecule_failed", b.ID, key) } // Found existing sub-DAG root. Ensure dep is wired. @@ -419,7 +419,7 @@ func existingAttachIDMapping(store beads.Store, recipe *formula.Recipe, rootBead return nil, err } for _, bead := range all { - if bead.Metadata["molecule_failed"] == "true" { + if bead.Metadata[beadmeta.MoleculeFailedMetadataKey] == "true" { continue } ref := strings.TrimSpace(bead.Metadata[beadmeta.StepRefMetadataKey]) @@ -1288,14 +1288,14 @@ func unresolvedTitleValidationErrorsWithVars(recipe *formula.Recipe, opts Option return errs } -// markFailed sets "molecule_failed" metadata on all created beads. +// markFailed sets beadmeta.MoleculeFailedMetadataKey on all created beads. // Best-effort: errors are silently ignored since we're already in an // error path. func markFailed(store beads.Store, ids []string) { for _, id := range ids { _ = store.SetMetadataBatch(id, map[string]string{ - "molecule_failed": "true", - InstantiatingMetadataKey: "", + beadmeta.MoleculeFailedMetadataKey: "true", + InstantiatingMetadataKey: "", }) } } diff --git a/internal/sling/sling.go b/internal/sling/sling.go index daf4aa3f72..ccb0e0ff53 100644 --- a/internal/sling/sling.go +++ b/internal/sling/sling.go @@ -1485,7 +1485,7 @@ func closeFailedGraphV2RootsByKey(store beads.Store, key string) error { return fmt.Errorf("looking up failed formulas v2 roots for key %s: %w", key, err) } for _, root := range matches { - if root.Status == "closed" || root.Metadata["molecule_failed"] != "true" { + if root.Status == "closed" || root.Metadata[beadmeta.MoleculeFailedMetadataKey] != "true" { continue } if _, err := sourceworkflow.CloseWorkflowSubtree(store, root.ID); err != nil { From e4bc0eb2ccdc196065ba5cd220a674fb3cead2cf Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 8 Jul 2026 20:22:59 -0700 Subject: [PATCH 020/225] refactor(api,session): dedup API session codecs via session.Info projections (#4051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Deletes `internal/api` re-implementations of confined `internal/session` codecs and routes the remaining API-side session-metadata cracks through `session.Info` projections. - **Mailbox address dedup** — `apiSessionMailboxAddress` deleted → `session.MailboxAddress`. `apiSessionMailboxAddresses` was **not** identical to `session.MailboxAddresses` (the API variant appends `session_name` *unconditionally last*, the CLI codec only as a last-resort fallback — a deliberate prior fix, `bf576b04a`), so rather than collapse them, a new `session.MailboxAddressesIncludingRuntimeName` shares a `mailboxAddresses(b, includeRuntimeName bool)` body and preserves the API semantics. - **Assignee identities** — new `session.AssigneeIdentities`/`AssigneeIdentifier` replace `internal/api`'s `sessionBeadAssigneeIdentifier`; `handler_beads.go` assignee enumeration routes through the codec. - **Session-metadata cracks** — `handler_sessions.go`, `huma_handlers_sessions_command.go`, `session_resolution.go`, and `cmd/gc/pool_session_name.go` read `session.Info` projections instead of raw `b.Metadata[...]`. `session_resolution.go` deliberately uses the raw `MetadataState` mirror (not `Info.State`, which folds awake→active). ## Behavior preservation All three equivalences verified byte-for-byte: mailbox address set + order, assignee identity forms (the assignee-term ordering delta is inert — the sole consumer dedupes by `(rig, ID)` and re-sorts with a total order), and the `MetadataState`-vs-`Info.State` distinction. The `cmd/gc` bead-form peer stays inline in the hot reconciler loop, guarded by the classifier-equivalence oracle. ## Verification - `go build ./...`, `go vet ./internal/session ./internal/api ./cmd/gc` clean; `gofmt` clean - `go test ./internal/session ./internal/api` green; `go test ./cmd/gc -run 'SessionBeadAssigneeIdentities|InfoEquiv|Classifier'` green - `bf576b04a` oracle `TestMailAPIQueriesAllResolvedSessionMailboxAddresses` + `TestOpenAPISpecInSync` green; `make test` exit 0 - TDD: `MailboxAddressesIncludingRuntimeName` / `AssigneeIdentities` / `AssigneeIdentifier` pin tests written first - Fable adversarial review: approve (all equivalences confirmed) ## Follow-up (pre-existing, out of scope) Sibling raw session-metadata cracks remain at `internal/api/handler_status.go` (~503-505, 740) and `huma_handlers_sessions_query.go:296` — a later slice can route them through `session.InfoFromPersistedBead` (the raw mirrors already exist). Part of the raw-bead-leak cleanup epic. Refs `ga-7jftpc`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/gc/pool_session_name.go | 25 +-- internal/api/handler_beads.go | 36 +--- internal/api/handler_beads_test.go | 39 ++++ internal/api/handler_mail.go | 40 +---- internal/api/handler_sessions.go | 8 +- .../api/huma_handlers_sessions_command.go | 6 +- internal/api/session_resolution.go | 6 +- internal/session/assignee_identities.go | 65 +++++++ internal/session/assignee_identities_test.go | 170 ++++++++++++++++++ internal/session/mailbox_address.go | 28 ++- internal/session/mailbox_address_test.go | 47 +++++ 11 files changed, 378 insertions(+), 92 deletions(-) create mode 100644 internal/session/assignee_identities.go create mode 100644 internal/session/assignee_identities_test.go diff --git a/cmd/gc/pool_session_name.go b/cmd/gc/pool_session_name.go index bf39c48632..3bf41860fb 100644 --- a/cmd/gc/pool_session_name.go +++ b/cmd/gc/pool_session_name.go @@ -46,27 +46,12 @@ func sessionBeadAssigneeIdentities(sb beads.Bead) []string { // sessionBeadAssigneeIdentitiesInfo is the session.Info mirror of // sessionBeadAssigneeIdentities. It reads the RAW session_name -// (Info.SessionNameMetadata) and the pre-normalized Info.AliasHistory. +// (Info.SessionNameMetadata) and the pre-normalized Info.AliasHistory. The body +// is the confined session.AssigneeIdentities codec; the bead-form peer above +// stays inline to avoid a per-iteration Info projection in the hot reconciler +// loops (the classifier-equivalence oracle guards their agreement). func sessionBeadAssigneeIdentitiesInfo(i session.Info) []string { - identities := make([]string, 0, 5) - if id := strings.TrimSpace(i.ID); id != "" { - identities = append(identities, id) - } - if sn := strings.TrimSpace(i.SessionNameMetadata); sn != "" { - identities = append(identities, sn) - } - if ni := strings.TrimSpace(i.ConfiguredNamedIdentity); ni != "" { - identities = append(identities, ni) - } - if al := strings.TrimSpace(i.Alias); al != "" { - identities = append(identities, al) - } - for _, prior := range i.AliasHistory { - if prior = strings.TrimSpace(prior); prior != "" { - identities = append(identities, prior) - } - } - return identities + return session.AssigneeIdentities(i) } type releasedPoolAssignment struct { diff --git a/internal/api/handler_beads.go b/internal/api/handler_beads.go index 5e78c263ca..375c699754 100644 --- a/internal/api/handler_beads.go +++ b/internal/api/handler_beads.go @@ -62,10 +62,10 @@ func (s *Server) beadListAssigneeTerms(ctx context.Context, assignee string) []s } // A work bead's stored assignee may be ANY of the resolved session's // identity forms — the bead ID, session_name, alias, configured named - // identity, or a prior alias — so match against all of them (mirrors - // sessionBeadAssigneeIdentities used by the reconciler). Without this the - // session-name form written by assign/update (and the claim path) would be - // invisible to ?assignee= list filters. + // identity, or a prior alias — so match against all of them via the confined + // session.AssigneeIdentities codec. Without this the session-name form + // written by assign/update (and the claim path) would be invisible to + // ?assignee= list filters. seen := map[string]bool{} var terms []string add := func(v string) { @@ -79,11 +79,8 @@ func (s *Server) beadListAssigneeTerms(ctx context.Context, assignee string) []s add(assignee) add(id) if b, getErr := store.Get(id); getErr == nil { - add(b.Metadata["session_name"]) - add(b.Metadata["alias"]) - add(b.Metadata[session.NamedSessionIdentityMetadata]) - for _, prior := range session.AliasHistory(b.Metadata) { - add(prior) + for _, identity := range session.AssigneeIdentities(session.InfoFromPersistedBead(b)) { + add(identity) } } return terms @@ -116,26 +113,7 @@ func (s *Server) normalizeRawBeadAssignee(ctx context.Context, assignee string) return "", fmt.Errorf("assignee must resolve to a concrete open session bead ID: %q", assignee) } session.RepairEmptyType(store, &b) - return sessionBeadAssigneeIdentifier(b), nil -} - -// sessionBeadAssigneeIdentifier returns the durable agent-facing identity form -// of a session bead — its session_name, else alias, else configured named -// identity — falling back to the bead ID when no name metadata is present so a -// resolved assignment is never silently cleared. This is the form the agent -// claims and verifies work with (BEADS_ACTOR / GC_SESSION_NAME), so stamping it -// keeps assign/update consistent with the claim path (which already stores the -// raw session-name) and with the form-agnostic session matching in the -// reconciler (sessionBeadAssigneeIdentities). Stamping the bare bead ID here -// instead made template-routed continuation work unclaimable by name-matching -// agents. -func sessionBeadAssigneeIdentifier(b beads.Bead) string { - for _, key := range []string{"session_name", "alias", session.NamedSessionIdentityMetadata} { - if v := strings.TrimSpace(b.Metadata[key]); v != "" { - return v - } - } - return b.ID + return session.AssigneeIdentifier(session.InfoFromPersistedBead(b)), nil } // findStore returns the bead store for the given rig. If rig is empty, returns diff --git a/internal/api/handler_beads_test.go b/internal/api/handler_beads_test.go index ae1b1a581f..5d741ec3e5 100644 --- a/internal/api/handler_beads_test.go +++ b/internal/api/handler_beads_test.go @@ -1800,6 +1800,45 @@ func TestPhase2BeadListAssigneeAliasKeepsCrossRigDuplicateIDs(t *testing.T) { } } +// TestBeadListAssigneeTermsIncludesAllSessionIdentityForms pins the term SET +// beadListAssigneeTerms enumerates for a resolved session: the input term, the +// bead ID, session_name, alias, configured named identity, and every prior +// alias. Order is not part of the contract (huma_handlers_beads re-sorts +// globally when len(terms)>1); the set must stay stable across the codec swap +// onto session.AssigneeIdentities. +func TestBeadListAssigneeTermsIncludesAllSessionIdentityForms(t *testing.T) { + state := newFakeState(t) + state.cityBeadStore = beads.NewMemStore() + sessionBead, err := state.cityBeadStore.Create(beads.Bead{ + Title: "Worker session", + Type: session.BeadType, + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "session_name": "test-city--worker", + "alias": "worker", + "configured_named_identity": "reviewer", + "alias_history": "nux,rictus", + "template": "myrig/worker", + "state": "active", + }, + }) + if err != nil { + t.Fatalf("Create(session): %v", err) + } + srv := New(state) + + terms := srv.beadListAssigneeTerms(context.Background(), "worker") + got := make(map[string]bool, len(terms)) + for _, term := range terms { + got[term] = true + } + for _, want := range []string{"worker", sessionBead.ID, "test-city--worker", "reviewer", "nux", "rictus"} { + if !got[want] { + t.Errorf("beadListAssigneeTerms(worker) missing %q; got %v", want, terms) + } + } +} + func TestPhase2BeadAssignNormalizesCurrentSessionName(t *testing.T) { state := newFakeState(t) state.cityBeadStore = beads.NewMemStore() diff --git a/internal/api/handler_mail.go b/internal/api/handler_mail.go index 4a7873a5cf..1da0c5eaa3 100644 --- a/internal/api/handler_mail.go +++ b/internal/api/handler_mail.go @@ -92,7 +92,7 @@ func (s *Server) resolveMailSendRecipientWithContext(ctx context.Context, recipi if getErr != nil { return "", getErr } - address := apiSessionMailboxAddress(bead) + address := session.MailboxAddress(bead) if address == "" { return "", fmt.Errorf("session %q has no mailbox identity", recipient) } @@ -142,7 +142,7 @@ func (s *Server) resolveMailQueryRecipientsWithContext(ctx context.Context, reci return []string{recipient} } if bead, getErr := store.Get(resolved); getErr == nil { - if recipients := apiSessionMailboxAddresses(bead); len(recipients) > 0 { + if recipients := session.MailboxAddressesIncludingRuntimeName(bead); len(recipients) > 0 { return recipients } } @@ -177,7 +177,7 @@ func (s *Server) mailRecipientsForNamedSession(store beads.Store, spec apiNamedS continue } seen[b.ID] = true - recipients = append(recipients, apiSessionMailboxAddresses(b)...) + recipients = append(recipients, session.MailboxAddressesIncludingRuntimeName(b)...) } recipients = uniqueNonEmptyMailRecipients(recipients) sort.Strings(recipients) @@ -215,36 +215,6 @@ type apiResolvedMailTarget struct { recipients []string } -func apiSessionMailboxAddress(b beads.Bead) string { - if alias := strings.TrimSpace(b.Metadata["alias"]); alias != "" { - return alias - } - if b.ID != "" { - return b.ID - } - return strings.TrimSpace(b.Metadata["session_name"]) -} - -func apiSessionMailboxAddresses(b beads.Bead) []string { - seen := map[string]bool{} - var addresses []string - add := func(value string) { - value = strings.TrimSpace(value) - if value == "" || seen[value] { - return - } - seen[value] = true - addresses = append(addresses, value) - } - add(apiSessionMailboxAddress(b)) - add(b.ID) - for _, alias := range session.AliasHistory(b.Metadata) { - add(alias) - } - add(b.Metadata["session_name"]) - return addresses -} - func (s *Server) resolveLiveConfiguredNamedMailTarget(store beads.Store, identifier string) (apiResolvedMailTarget, bool, error) { identifier = apiNormalizeSessionTarget(identifier) if store == nil || identifier == "" || identifier == "human" || strings.Contains(identifier, "/") { @@ -281,11 +251,11 @@ func (s *Server) resolveLiveConfiguredNamedMailTarget(store beads.Store, identif if identity == "" || session.TargetBasename(identity) != identifier { continue } - addresses := apiSessionMailboxAddresses(b) + addresses := session.MailboxAddressesIncludingRuntimeName(b) if len(addresses) == 0 { continue } - display := apiSessionMailboxAddress(b) + display := session.MailboxAddress(b) if display == "" { display = addresses[0] } diff --git a/internal/api/handler_sessions.go b/internal/api/handler_sessions.go index 2e8301fb79..8fe3fce7bd 100644 --- a/internal/api/handler_sessions.go +++ b/internal/api/handler_sessions.go @@ -491,8 +491,10 @@ func (s *Server) handleSessionWake(w http.ResponseWriter, r *http.Request) { log.Printf("gc api: withdrawing queued wait nudges after wake %s: %v", id, err) } // Clear in-memory crash tracker so the reconciler doesn't immediately - // re-quarantine the session based on stale crash history. - sessionName := b.Metadata["session_name"] + // re-quarantine the session based on stale crash history. Read the RAW + // SessionNameMetadata (not Info.SessionName, which falls back to + // sessionNameFor(ID)) to preserve the skip-when-unset behavior. + sessionName := session.InfoFromPersistedBead(b).SessionNameMetadata if sessionName != "" { s.state.ClearCrashHistory(sessionName) } @@ -757,7 +759,7 @@ func (s *Server) handleSessionPatch(w http.ResponseWriter, r *http.Request) { return catalog.UpdatePresentation(id, titlePtr, aliasPtr) } if aliasPtr != nil { - if strings.TrimSpace(b.Metadata["agent_name"]) != "" { + if strings.TrimSpace(session.InfoFromPersistedBead(b).AgentName) != "" { writeError(w, http.StatusForbidden, "forbidden", "alias is controller-managed for this session") return } diff --git a/internal/api/huma_handlers_sessions_command.go b/internal/api/huma_handlers_sessions_command.go index 3e3ac791d8..3db8b708fe 100644 --- a/internal/api/huma_handlers_sessions_command.go +++ b/internal/api/huma_handlers_sessions_command.go @@ -430,7 +430,7 @@ func (s *Server) humaHandleSessionPatch(_ context.Context, input *SessionPatchIn return mgr.UpdatePresentation(id, titlePtr, aliasPtr) } if aliasPtr != nil { - if strings.TrimSpace(b.Metadata["agent_name"]) != "" { + if strings.TrimSpace(session.InfoFromPersistedBead(b).AgentName) != "" { return nil, huma.Error403Forbidden("forbidden: alias is controller-managed for this session") } if lockErr := session.WithCitySessionAliasLock(s.state.CityPath(), *aliasPtr, func() error { @@ -887,7 +887,9 @@ func (s *Server) humaHandleSessionWake(ctx context.Context, input *SessionIDInpu if err := withdrawQueuedWaitNudges(s.state.NudgesBeadStore(), s.state.CityPath(), nudgeIDs); err != nil { log.Printf("gc api: withdrawing queued wait nudges after wake %s: %v", id, err) } - sessionName := b.Metadata["session_name"] + // RAW SessionNameMetadata (not Info.SessionName, which falls back to + // sessionNameFor(ID)) to preserve the skip-when-unset behavior. + sessionName := session.InfoFromPersistedBead(b).SessionNameMetadata if sessionName != "" { s.state.ClearCrashHistory(sessionName) } diff --git a/internal/api/session_resolution.go b/internal/api/session_resolution.go index a06e84482c..d4547f251f 100644 --- a/internal/api/session_resolution.go +++ b/internal/api/session_resolution.go @@ -163,7 +163,7 @@ func (s *Server) retireContinuityIneligibleNamedSessionIdentifiers(store beads.S retired = append(retired, b) continue } - if sessionName := strings.TrimSpace(b.Metadata["session_name"]); sessionName != "" && s.state.SessionProvider() != nil { + if sessionName := strings.TrimSpace(session.InfoFromPersistedBead(b).SessionNameMetadata); sessionName != "" && s.state.SessionProvider() != nil { if handle, err := s.workerHandleForSession(store, b.ID); err == nil { _ = handle.Kill(context.Background()) } @@ -432,7 +432,9 @@ func resolveLiveSessionByPathAlias(store beads.Store, identifier string) (string if strings.TrimSpace(b.Title) != identifier { continue } - state := session.State(b.Metadata["state"]) + // MetadataState is the RAW state mirror; Info.State is normalizeInfoState- + // folded (awake->active), which would change this predicate. + state := session.State(session.InfoFromPersistedBead(b).MetadataState) if state != session.StateActive && state != session.StateAwake && state != session.StateNone { continue } diff --git a/internal/session/assignee_identities.go b/internal/session/assignee_identities.go new file mode 100644 index 0000000000..e26714a167 --- /dev/null +++ b/internal/session/assignee_identities.go @@ -0,0 +1,65 @@ +package session + +import "strings" + +// This file is the confined session-class assignee-identity vocabulary: the +// forms under which a work bead may be assigned to a session. It is shared by +// the reconciler orphan-release loops (which enumerate every form a live +// session answers to) and the API assignee list filter and assign stamper +// (which enumerate the same set and pick the durable stamp form). Confining it +// here keeps the session-bead metadata keys (session_name / alias / +// configured_named_identity / alias_history) out of cmd/gc and internal/api, so +// those callers speak session identities via session.Info instead of cracking +// beads.Bead.Metadata directly. +// +// All reads use the RAW Info mirrors (SessionNameMetadata, not SessionName) +// because Info.SessionName falls back to sessionNameFor(ID); admitting that +// derived runtime name into the assignee set would match work the session was +// never assigned. + +// AssigneeIdentities returns every identifier under which a work bead could be +// assigned to this session: the session bead ID, session_name, +// configured_named_identity, current alias, and any prior aliases preserved in +// alias_history — each trimmed, empty values skipped, in that order. Pool +// polecat aliases (e.g. "nux") are first-class assignment identities, so +// leaving them out of orphan-detection resets in-progress work under a live +// owner — see the SkipsLiveSessionAssignedByAlias regression tests. +func AssigneeIdentities(i Info) []string { + identities := make([]string, 0, 5) + if id := strings.TrimSpace(i.ID); id != "" { + identities = append(identities, id) + } + if sn := strings.TrimSpace(i.SessionNameMetadata); sn != "" { + identities = append(identities, sn) + } + if ni := strings.TrimSpace(i.ConfiguredNamedIdentity); ni != "" { + identities = append(identities, ni) + } + if al := strings.TrimSpace(i.Alias); al != "" { + identities = append(identities, al) + } + for _, prior := range i.AliasHistory { + if prior = strings.TrimSpace(prior); prior != "" { + identities = append(identities, prior) + } + } + return identities +} + +// AssigneeIdentifier returns the durable agent-facing identity form of a +// session — its session_name, else alias, else configured named identity — +// falling back to the bead ID when no name metadata is present so a resolved +// assignment is never silently cleared. This is the form the agent claims and +// verifies work with (BEADS_ACTOR / GC_SESSION_NAME), so stamping it keeps +// assign/update consistent with the claim path (which already stores the raw +// session-name) and with the form-agnostic matching in AssigneeIdentities. +// Stamping the bare bead ID here instead made template-routed continuation work +// unclaimable by name-matching agents. +func AssigneeIdentifier(i Info) string { + for _, v := range []string{i.SessionNameMetadata, i.Alias, i.ConfiguredNamedIdentity} { + if v = strings.TrimSpace(v); v != "" { + return v + } + } + return i.ID +} diff --git a/internal/session/assignee_identities_test.go b/internal/session/assignee_identities_test.go new file mode 100644 index 0000000000..28159d223f --- /dev/null +++ b/internal/session/assignee_identities_test.go @@ -0,0 +1,170 @@ +package session + +import ( + "reflect" + "testing" + + "github.com/gastownhall/gascity/internal/beads" +) + +// The assignee-identity codec is the confined vocabulary shared by the +// reconciler orphan-release loops and the API assignee list filter/stamper. +// These tests pin AssigneeIdentities to the cmd/gc sessionBeadAssigneeIdentities +// case table it replaces (via InfoFromPersistedBead, proving bead<->Info +// agreement) and pin AssigneeIdentifier to the internal/api +// sessionBeadAssigneeIdentifier precedence it replaces, so the enumerated term +// set and the stamped identity form stay byte-identical after the dedup. A +// direct-Info case proves the RAW SessionNameMetadata field is read (no +// sessionNameFor(ID) fallback leak). + +func TestAssigneeIdentities(t *testing.T) { + tests := []struct { + name string + bead beads.Bead + want []string + }{ + { + name: "empty bead produces no identities", + bead: beads.Bead{}, + want: []string{}, + }, + { + name: "id only", + bead: beads.Bead{ID: "mc-xyz"}, + want: []string{"mc-xyz"}, + }, + { + name: "session_name only", + bead: beads.Bead{Metadata: map[string]string{"session_name": "worker-mc-live"}}, + want: []string{"worker-mc-live"}, + }, + { + name: "configured_named_identity only", + bead: beads.Bead{Metadata: map[string]string{"configured_named_identity": "reviewer"}}, + want: []string{"reviewer"}, + }, + { + name: "alias only", + bead: beads.Bead{Metadata: map[string]string{"alias": "nux"}}, + want: []string{"nux"}, + }, + { + name: "alias_history single entry", + bead: beads.Bead{Metadata: map[string]string{"alias_history": "previous"}}, + want: []string{"previous"}, + }, + { + name: "alias_history multiple entries", + bead: beads.Bead{Metadata: map[string]string{"alias_history": "first,second,third"}}, + want: []string{"first", "second", "third"}, + }, + { + name: "all fields populated", + bead: beads.Bead{ + ID: "mc-xyz", + Metadata: map[string]string{ + "session_name": "worker-mc-live", + "configured_named_identity": "reviewer", + "alias": "rictus", + "alias_history": "nux", + }, + }, + want: []string{"mc-xyz", "worker-mc-live", "reviewer", "rictus", "nux"}, + }, + { + name: "whitespace-only values are trimmed and skipped", + bead: beads.Bead{ + ID: " ", + Metadata: map[string]string{ + "session_name": " ", + "configured_named_identity": "\t", + "alias": " ", + "alias_history": " , , real , ", + }, + }, + want: []string{"real"}, + }, + { + name: "values with surrounding whitespace are trimmed", + bead: beads.Bead{ + ID: " mc-xyz ", + Metadata: map[string]string{ + "session_name": " worker-mc-live ", + "configured_named_identity": " reviewer ", + "alias": " nux ", + }, + }, + want: []string{"mc-xyz", "worker-mc-live", "reviewer", "nux"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := AssigneeIdentities(InfoFromPersistedBead(tt.bead)) + if len(got) != len(tt.want) { + t.Fatalf("got %d identities %v, want %d %v", len(got), got, len(tt.want), tt.want) + } + for i, id := range got { + if id != tt.want[i] { + t.Errorf("identity[%d] = %q, want %q (full got=%v, want=%v)", i, id, tt.want[i], got, tt.want) + } + } + }) + } +} + +// TestAssigneeIdentitiesReadsRawSessionName proves AssigneeIdentities reads the +// RAW SessionNameMetadata field, not Info.SessionName (which falls back to +// sessionNameFor(ID)). A blank SessionNameMetadata must not leak the derived +// runtime name into the identity set. +func TestAssigneeIdentitiesReadsRawSessionName(t *testing.T) { + i := Info{ID: "s1", SessionName: "s-gc-derived", SessionNameMetadata: ""} + if got, want := AssigneeIdentities(i), []string{"s1"}; !reflect.DeepEqual(got, want) { + t.Errorf("AssigneeIdentities = %#v, want %#v (must not leak sessionNameFor(ID))", got, want) + } +} + +func TestAssigneeIdentifier(t *testing.T) { + tests := []struct { + name string + info Info + want string + }{ + { + name: "session_name wins", + info: Info{ID: "s1", SessionNameMetadata: "sn", Alias: "al", ConfiguredNamedIdentity: "ni"}, + want: "sn", + }, + { + name: "alias when no session_name", + info: Info{ID: "s1", Alias: "al", ConfiguredNamedIdentity: "ni"}, + want: "al", + }, + { + name: "configured named identity when no session_name or alias", + info: Info{ID: "s1", ConfiguredNamedIdentity: "ni"}, + want: "ni", + }, + { + name: "bead id fallback when no name metadata", + info: Info{ID: "s1"}, + want: "s1", + }, + { + name: "whitespace-only values skipped, falls through to id", + info: Info{ID: "s1", SessionNameMetadata: " ", Alias: "\t", ConfiguredNamedIdentity: " "}, + want: "s1", + }, + { + name: "values trimmed", + info: Info{ID: "s1", SessionNameMetadata: " sn "}, + want: "sn", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := AssigneeIdentifier(tt.info); got != tt.want { + t.Errorf("AssigneeIdentifier = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/session/mailbox_address.go b/internal/session/mailbox_address.go index 17aa1b5d69..7ddc8debca 100644 --- a/internal/session/mailbox_address.go +++ b/internal/session/mailbox_address.go @@ -34,6 +34,30 @@ func MailboxAddress(b beads.Bead) string { // is the canonical home of the logic the mail CLI previously inlined as // sessionMailboxAddresses. func MailboxAddresses(b beads.Bead) []string { + return mailboxAddresses(b, false) +} + +// MailboxAddressesIncludingRuntimeName returns every mailbox address a session +// bead can receive mail at, always including its runtime session_name (appended +// last), even when other addresses already resolved. This is the API read +// semantics introduced by bf576b04a ("fix: include runtime session mailboxes in +// API reads"): mail persisted under a session's runtime name must stay +// reachable via API inbox/count queries, guarded by +// TestMailAPIQueriesAllResolvedSessionMailboxAddresses. +// +// It deliberately forks from MailboxAddresses, which appends session_name only +// as a last-resort fallback. That CLI/API fork is a documented product decision +// (the CLI inbox can miss mail persisted under a runtime session_name the API +// finds); reconciling the two recipient views is tracked as a follow-up. +func MailboxAddressesIncludingRuntimeName(b beads.Bead) []string { + return mailboxAddresses(b, true) +} + +// mailboxAddresses is the shared body behind MailboxAddresses (CLI fallback-only +// session_name) and MailboxAddressesIncludingRuntimeName (API unconditional +// session_name). When includeRuntimeName is true the session_name is always +// added; otherwise it is only added when nothing else resolved. +func mailboxAddresses(b beads.Bead, includeRuntimeName bool) []string { seen := map[string]bool{} var addresses []string add := func(value string) { @@ -49,7 +73,9 @@ func MailboxAddresses(b beads.Bead) []string { for _, alias := range AliasHistory(b.Metadata) { add(alias) } - if len(addresses) == 0 { + if includeRuntimeName { + add(b.Metadata["session_name"]) + } else if len(addresses) == 0 { add(strings.TrimSpace(b.Metadata["session_name"])) } return addresses diff --git a/internal/session/mailbox_address_test.go b/internal/session/mailbox_address_test.go index d94c381930..52b1884e7d 100644 --- a/internal/session/mailbox_address_test.go +++ b/internal/session/mailbox_address_test.go @@ -95,6 +95,53 @@ func TestMailboxAddressesCodec(t *testing.T) { } } +// TestMailboxAddressesIncludingRuntimeNameCodec pins the API-dup semantics that +// MailboxAddressesIncludingRuntimeName preserves: unlike MailboxAddresses (the +// CLI fork, tested above), it appends session_name UNCONDITIONALLY (last), +// keeping runtime session mailboxes reachable via API reads (bf576b04a). +func TestMailboxAddressesIncludingRuntimeNameCodec(t *testing.T) { + tests := []struct { + name string + bead beads.Bead + want []string + }{ + { + name: "session_name appended last even when other addresses resolve", + bead: beads.Bead{ + ID: "sess-1", + Metadata: map[string]string{ + "alias": "mayor", + "alias_history": "deacon,mayor,polecat", + "session_name": "sn-1", + }, + }, + want: []string{"mayor", "sess-1", "deacon", "polecat", "sn-1"}, + }, + { + name: "session_name deduped against primary", + bead: beads.Bead{Metadata: map[string]string{"session_name": "sn-1"}}, + want: []string{"sn-1"}, + }, + { + name: "id only", + bead: beads.Bead{ID: "sess-1"}, + want: []string{"sess-1"}, + }, + { + name: "empty everything", + bead: beads.Bead{}, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := MailboxAddressesIncludingRuntimeName(tt.bead); !reflect.DeepEqual(got, tt.want) { + t.Errorf("MailboxAddressesIncludingRuntimeName = %#v, want %#v", got, tt.want) + } + }) + } +} + func TestExtmsgHandleSourceCodec(t *testing.T) { tests := []struct { name string From d87453d671542d8d501698960aff10c65483d2ec Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 8 Jul 2026 20:34:30 -0700 Subject: [PATCH 021/225] refactor(session,wait): route gc wait through a typed session.WaitInfo codec (#4056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Introduces a typed `session.WaitInfo` codec (confined to `internal/session/waits.go`) and routes `gc wait`'s read/render/decision paths through it, eliminating the inline `objectFromBead(bead).` cracking — the clearest textbook instance of the antipattern. - `internal/session/waits.go` — new `WaitInfo` + `WaitInfoFromBead` codec (+ `splitWaitDepIDs`, a verbatim move of the old `splitWaitIDs`); `ListSessionWaitBeads` → `ListSessionWaits` returning `[]WaitInfo`; the 3 in-package consumers retyped. - `cmd/gc/cmd_wait.go` — `waitJSONFromBead`→`waitJSONFromInfo`, `writeWaitDetail`, all loaders, deps-readiness, `waitNudgeID`, `nextWaitDeliveryAttempt`, `readyWaitSetForList` build from `WaitInfo`. **Metadata reads dropped 48 → 4** (the 4 survivors are *session*-bead readers — the separate session.Info opportunity, out of scope). - Write codecs (`retryClosedWait`, `setWaitTerminalState`, the `cmdSessionWait` meta map) deliberately stay on raw beads — no over-reach. ## Behavior preservation `WaitInfoFromBead` decodes the same 10 keys with identical absent-key defaults; the `schema_version`-1 CLI JSON is byte-identical (pinned by `TestWaitJSONFromInfo_MatchesBeadProjection`, re-run independently by review). **One display-only nit (documented, accepted):** `writeWaitDetail`'s Deps line now renders the normalized (trimmed, empties-dropped) `DepIDs` join. This is byte-identical for every in-tree writer (the only writer joins comma-no-space; retry clones verbatim); it differs only for out-of-band hand-edited metadata like `"gc-1, gc-2"`, which no system code produces. Reverting would re-introduce a raw `b.Metadata` read, so it's left as the normalized form. ## Verification - `gofmt` clean; `go build ./...`, `go vet ./internal/session ./cmd/gc` and `go vet ./...` clean - `go test ./internal/session` green; `go test ./cmd/gc -run Wait` green (incl. new equivalence + golden tests); acceptance `Wait` green — all independently re-run at the branch tip - No wire/OpenAPI/dashboard impact - Fable adversarial review: approve (equivalence independently re-verified; the one-off `make test` blip is the documented `eventfeed TestMuxSource` parallel-sweep flake, not a regression) Part of the raw-bead-leak cleanup epic. Refs `ga-qjcta7`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/gc/bead_policy_store_test.go | 22 ++- cmd/gc/cmd_wait.go | 236 +++++++++++++-------------- cmd/gc/cmd_wait_test.go | 130 ++++++++++++--- cmd/gc/session_beads.go | 2 +- internal/coordclass/classify_test.go | 2 +- internal/session/waits.go | 128 +++++++++++++-- internal/session/waits_test.go | 108 +++++++++++- 7 files changed, 445 insertions(+), 183 deletions(-) diff --git a/cmd/gc/bead_policy_store_test.go b/cmd/gc/bead_policy_store_test.go index 8896c98fd5..9aecb79fd1 100644 --- a/cmd/gc/bead_policy_store_test.go +++ b/cmd/gc/bead_policy_store_test.go @@ -569,22 +569,26 @@ func TestPolicyReadPathsIncludeHistoryAndNoHistoryRows(t *testing.T) { t.Fatalf("new session row = %+v, want no_history parsed", sessions[1]) } - waits, err := loadWaitBeads(store) + // loadWaits returns session.WaitInfo, which deliberately omits the NoHistory + // storage detail (mirroring session.Info). The no-history row still flows + // through the retyped policy read path, so assert both wait IDs are present; + // the no_history parse assertion remains covered by the loadSessionBeads half + // above and by the bdstore tests. + waits, err := loadWaits(store) if err != nil { - t.Fatalf("loadWaitBeads: %v", err) + t.Fatalf("loadWaits: %v", err) } if len(waits) != 2 { t.Fatalf("waits = %+v, want history and no-history rows", waits) } - foundNoHistoryWait := false + waitIDs := map[string]bool{} for _, wait := range waits { - if wait.ID == "bd-new-wait" { - foundNoHistoryWait = wait.NoHistory - break - } + waitIDs[wait.ID] = true } - if !foundNoHistoryWait { - t.Fatalf("waits = %+v, want bd-new-wait with no_history parsed", waits) + for _, id := range []string{"bd-old-wait", "bd-new-wait"} { + if !waitIDs[id] { + t.Fatalf("waits = %+v, want both history and no-history rows (missing %s)", waits, id) + } } } diff --git a/cmd/gc/cmd_wait.go b/cmd/gc/cmd_wait.go index 6994c3a25c..7d4cf9b112 100644 --- a/cmd/gc/cmd_wait.go +++ b/cmd/gc/cmd_wait.go @@ -270,7 +270,7 @@ func cmdSessionWait(args, depIDs []string, matchAny bool, note string, sleep boo fmt.Fprintf(stderr, "gc session wait: creating wait: %v\n", err) //nolint:errcheck return 1 } - ready, depErr := depsWaitReadyDetailedForCity(cityPath, store, waitBead) + ready, depErr := depsWaitReadyDetailedForCity(cityPath, store, sessionpkg.WaitInfoFromBead(waitBead)) if depErr != nil { if err := setWaitTerminalState(sessStore, waitBead.ID, map[string]string{ "state": waitStateFailed, @@ -370,7 +370,7 @@ func routeWaitList(cityPath string, c *api.Client, nilReason, stateFilter, sessi // a stray non-wait bead tagged gc:wait would otherwise leak through. IsWaitBead // also covers the legacy "wait" type for back-compat with older stores. func renderWaitListFromAPI(cityPath string, cr api.CachedRead[[]beads.Bead], stateFilter, sessionFilter string, jsonOutput bool, stdout, stderr io.Writer) int { - items := make([]beads.Bead, 0, len(cr.Body)) + items := make([]sessionpkg.WaitInfo, 0, len(cr.Body)) for _, item := range cr.Body { if item.Status == "closed" { continue @@ -378,7 +378,7 @@ func renderWaitListFromAPI(cityPath string, cr api.CachedRead[[]beads.Bead], sta if !sessionpkg.IsWaitBead(item) { continue } - items = append(items, item) + items = append(items, sessionpkg.WaitInfoFromBead(item)) } sort.SliceStable(items, func(i, j int) bool { return items[i].CreatedAt.Before(items[j].CreatedAt) }) filtered := filterWaitListItems(items, stateFilter, sessionFilter) @@ -405,11 +405,11 @@ func doWaitListFallback(cityPath, stateFilter, sessionFilter string, jsonOutput // Route SESSION/wait access to the session coordination-class store; identity today. cfg, _ := loadCityConfigWithoutBuiltinPackRefresh(cityPath, io.Discard) sessStore := cliSessionStore(store, cfg, cityPath) - var items []beads.Bead + var items []sessionpkg.WaitInfo if sessionFilter != "" { - items, err = loadSessionWaitBeads(sessStore, sessionFilter) + items, err = loadSessionWaits(sessStore, sessionFilter) } else { - items, err = loadWaitBeads(sessStore) + items, err = loadWaits(sessStore) } if err != nil { if !isWaitLookupLimitError(err) { @@ -427,13 +427,13 @@ func doWaitListFallback(cityPath, stateFilter, sessionFilter string, jsonOutput return 0 } -func filterWaitListItems(items []beads.Bead, stateFilter, sessionFilter string) []beads.Bead { - filtered := make([]beads.Bead, 0, len(items)) +func filterWaitListItems(items []sessionpkg.WaitInfo, stateFilter, sessionFilter string) []sessionpkg.WaitInfo { + filtered := make([]sessionpkg.WaitInfo, 0, len(items)) for _, item := range items { - if stateFilter != "" && item.Metadata["state"] != stateFilter { + if stateFilter != "" && item.State != stateFilter { continue } - if sessionFilter != "" && item.Metadata["session_id"] != sessionFilter { + if sessionFilter != "" && item.SessionID != sessionFilter { continue } filtered = append(filtered, item) @@ -441,15 +441,15 @@ func filterWaitListItems(items []beads.Bead, stateFilter, sessionFilter string) return filtered } -func writeWaitListTable(items []beads.Bead, stdout io.Writer) { +func writeWaitListTable(items []sessionpkg.WaitInfo, stdout io.Writer) { tw := tabwriter.NewWriter(stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(tw, "WAIT\tSESSION\tSTATE\tKIND\tNOTE") //nolint:errcheck for _, item := range items { - note := item.Description + note := item.Note if note == "" { note = "-" } - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", item.ID, item.Metadata["session_id"], item.Metadata["state"], item.Metadata["kind"], note) //nolint:errcheck + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", item.ID, item.SessionID, item.State, item.Kind, note) //nolint:errcheck } _ = tw.Flush() } @@ -500,10 +500,11 @@ func renderWaitInspectFromAPI(cityPath string, cr api.CachedRead[beads.Bead], wa fmt.Fprintf(stderr, "gc wait inspect: %s is not a wait\n", waitID) //nolint:errcheck return 1 } + wait := sessionpkg.WaitInfoFromBead(cr.Body) if jsonOutput { - return writeWaitInspectJSON(stdout, stderr, cityPath, cr.Body) + return writeWaitInspectJSON(stdout, stderr, cityPath, wait) } - writeWaitDetail(cr.Body, stdout) + writeWaitDetail(wait, stdout) if cr.AgeSeconds > cacheAgeBannerThresholdSeconds { fmt.Fprintf(stdout, "(cache age: %.0fs — reconciler may be lagging)\n", cr.AgeSeconds) //nolint:errcheck } @@ -532,23 +533,24 @@ func doWaitInspectFallback(cityPath, waitID string, jsonOutput bool, stdout, std fmt.Fprintf(stderr, "gc wait inspect: %s is not a wait\n", waitID) //nolint:errcheck return 1 } + wait := sessionpkg.WaitInfoFromBead(b) if jsonOutput { - return writeWaitInspectJSON(stdout, stderr, cityPath, b) + return writeWaitInspectJSON(stdout, stderr, cityPath, wait) } - writeWaitDetail(b, stdout) + writeWaitDetail(wait, stdout) return 0 } -func writeWaitDetail(b beads.Bead, stdout io.Writer) { - fmt.Fprintf(stdout, "Wait: %s\n", b.ID) //nolint:errcheck - fmt.Fprintf(stdout, "Session: %s\n", b.Metadata["session_id"]) //nolint:errcheck - fmt.Fprintf(stdout, "State: %s\n", b.Metadata["state"]) //nolint:errcheck - fmt.Fprintf(stdout, "Kind: %s\n", b.Metadata["kind"]) //nolint:errcheck - fmt.Fprintf(stdout, "Deps: %s (%s)\n", b.Metadata["dep_ids"], b.Metadata["dep_mode"]) //nolint:errcheck - fmt.Fprintf(stdout, "Epoch: %s\n", b.Metadata["registered_epoch"]) //nolint:errcheck - fmt.Fprintf(stdout, "Attempt: %s\n", b.Metadata["delivery_attempt"]) //nolint:errcheck - fmt.Fprintf(stdout, "Nudge: %s\n", b.Metadata["nudge_id"]) //nolint:errcheck - fmt.Fprintf(stdout, "Note: %s\n", b.Description) //nolint:errcheck +func writeWaitDetail(w sessionpkg.WaitInfo, stdout io.Writer) { + fmt.Fprintf(stdout, "Wait: %s\n", w.ID) //nolint:errcheck + fmt.Fprintf(stdout, "Session: %s\n", w.SessionID) //nolint:errcheck + fmt.Fprintf(stdout, "State: %s\n", w.State) //nolint:errcheck + fmt.Fprintf(stdout, "Kind: %s\n", w.Kind) //nolint:errcheck + fmt.Fprintf(stdout, "Deps: %s (%s)\n", strings.Join(w.DepIDs, ","), w.DepMode) //nolint:errcheck + fmt.Fprintf(stdout, "Epoch: %s\n", w.RegisteredEpoch) //nolint:errcheck + fmt.Fprintf(stdout, "Attempt: %s\n", w.DeliveryAttempt) //nolint:errcheck + fmt.Fprintf(stdout, "Nudge: %s\n", w.NudgeID) //nolint:errcheck + fmt.Fprintf(stdout, "Note: %s\n", w.Note) //nolint:errcheck } type waitJSON struct { @@ -579,42 +581,28 @@ type waitInspectJSONEnvelope struct { Wait waitJSON `json:"wait"` } -func waitJSONFromBead(b beads.Bead) waitJSON { +func waitJSONFromInfo(w sessionpkg.WaitInfo) waitJSON { return waitJSON{ - ID: b.ID, - SessionID: b.Metadata["session_id"], - SessionName: b.Metadata["session_name"], - State: b.Metadata["state"], - Kind: b.Metadata["kind"], - DepIDs: splitWaitIDs(b.Metadata["dep_ids"]), - DepMode: b.Metadata["dep_mode"], - RegisteredEpoch: b.Metadata["registered_epoch"], - DeliveryAttempt: b.Metadata["delivery_attempt"], - NudgeID: b.Metadata["nudge_id"], - Note: b.Description, - Status: b.Status, - CreatedAt: formatOptionalTime(b.CreatedAt), - } -} - -func splitWaitIDs(value string) []string { - if strings.TrimSpace(value) == "" { - return nil - } - parts := strings.Split(value, ",") - out := make([]string, 0, len(parts)) - for _, part := range parts { - if trimmed := strings.TrimSpace(part); trimmed != "" { - out = append(out, trimmed) - } - } - return out -} - -func writeWaitListJSON(stdout, stderr io.Writer, cityPath string, waits []beads.Bead) int { + ID: w.ID, + SessionID: w.SessionID, + SessionName: w.SessionName, + State: w.State, + Kind: w.Kind, + DepIDs: w.DepIDs, + DepMode: w.DepMode, + RegisteredEpoch: w.RegisteredEpoch, + DeliveryAttempt: w.DeliveryAttempt, + NudgeID: w.NudgeID, + Note: w.Note, + Status: w.Status, + CreatedAt: formatOptionalTime(w.CreatedAt), + } +} + +func writeWaitListJSON(stdout, stderr io.Writer, cityPath string, waits []sessionpkg.WaitInfo) int { rows := make([]waitJSON, 0, len(waits)) for _, wait := range waits { - rows = append(rows, waitJSONFromBead(wait)) + rows = append(rows, waitJSONFromInfo(wait)) } payload := waitListJSONEnvelope{ SchemaVersion: "1", @@ -628,11 +616,11 @@ func writeWaitListJSON(stdout, stderr io.Writer, cityPath string, waits []beads. return 0 } -func writeWaitInspectJSON(stdout, stderr io.Writer, cityPath string, wait beads.Bead) int { +func writeWaitInspectJSON(stdout, stderr io.Writer, cityPath string, wait sessionpkg.WaitInfo) int { payload := waitInspectJSONEnvelope{ SchemaVersion: "1", CityPath: cityPath, - Wait: waitJSONFromBead(wait), + Wait: waitJSONFromInfo(wait), } if err := writeCLIJSONLine(stdout, payload); err != nil { fmt.Fprintf(stderr, "gc wait inspect: encode JSON: %v\n", err) //nolint:errcheck @@ -666,6 +654,9 @@ func cmdWaitSetStateResult(waitID, state string, stdout, stderr io.Writer) (wait fmt.Fprintf(stderr, "gc wait: %s is not a wait\n", waitID) //nolint:errcheck return result, 1 } + // Reads go through the typed WaitInfo projection; retryClosedWait keeps the + // raw bead because it clones metadata and re-creates the bead (write edge). + w := sessionpkg.WaitInfoFromBead(b) if state == waitStateReady { if err := waitLifecycleEnabled(); err != nil { fmt.Fprintf(stderr, "gc wait: %v\n", err) //nolint:errcheck @@ -673,7 +664,7 @@ func cmdWaitSetStateResult(waitID, state string, stdout, stderr io.Writer) (wait } } now := time.Now().UTC().Format(time.RFC3339) - if state == waitStateReady && b.Status == "closed" { + if state == waitStateReady && w.Status == "closed" { retried, err := retryClosedWait(sessStore, nudges, b, now) if err != nil { fmt.Fprintf(stderr, "gc wait: %v\n", err) //nolint:errcheck @@ -690,7 +681,7 @@ func cmdWaitSetStateResult(waitID, state string, stdout, stderr io.Writer) (wait switch state { case waitStateReady: batch["ready_at"] = now - nextAttempt, err := nextWaitDeliveryAttempt(nudgeFrontDoor(nudges), b) + nextAttempt, err := nextWaitDeliveryAttempt(nudgeFrontDoor(nudges), w) if err != nil { fmt.Fprintf(stderr, "gc wait: %v\n", err) //nolint:errcheck return result, 1 @@ -720,12 +711,12 @@ func cmdWaitSetStateResult(waitID, state string, stdout, stderr io.Writer) (wait } if state == waitStateCanceled { if cityPath, err := resolveCity(); err == nil { - if err := withdrawQueuedWaitNudges(cityPath, []string{b.Metadata["nudge_id"]}); err != nil { + if err := withdrawQueuedWaitNudges(cityPath, []string{w.NudgeID}); err != nil { fmt.Fprintf(stderr, "gc wait: withdrawing queued nudge: %v\n", err) //nolint:errcheck return result, 1 } } - if err := clearSessionWaitHoldIfIdle(sessStore, b.Metadata["session_id"]); err != nil { + if err := clearSessionWaitHoldIfIdle(sessStore, w.SessionID); err != nil { fmt.Fprintf(stderr, "gc wait: clearing session wait hold: %v\n", err) //nolint:errcheck return result, 1 } @@ -734,11 +725,11 @@ func cmdWaitSetStateResult(waitID, state string, stdout, stderr io.Writer) (wait return result, 0 } -func loadWaitBeads(store beads.Store) ([]beads.Bead, error) { +func loadWaits(store beads.Store) ([]sessionpkg.WaitInfo, error) { if store == nil { return nil, nil } - return loadWaitBeadsByLabel(store) + return loadWaitsByLabel(store) } // readyWaitSetForList returns the set of session IDs that have a ready wait @@ -750,22 +741,21 @@ func loadWaitBeads(store beads.Store) ([]beads.Bead, error) { // than in the session command file; `gc session list` consumes it to surface a // "wait" wake reason. func readyWaitSetForList(store beads.Store) (map[string]bool, error) { - items, err := loadWaitBeads(store) + items, err := loadWaits(store) ready := make(map[string]bool) for _, item := range items { - if item.Metadata["state"] != waitStateReady { + if item.State != waitStateReady { continue } - sessionID := item.Metadata["session_id"] - if sessionID != "" { - ready[sessionID] = true + if item.SessionID != "" { + ready[item.SessionID] = true } } return ready, err } -func loadSessionWaitBeads(store beads.Store, sessionID string) ([]beads.Bead, error) { - return sessionpkg.ListSessionWaitBeads(store, sessionID) +func loadSessionWaits(store beads.Store, sessionID string) ([]sessionpkg.WaitInfo, error) { + return sessionpkg.ListSessionWaits(store, sessionID) } const waitLookupLimit = sessionpkg.SessionWaitLookupLimit @@ -802,7 +792,7 @@ func stampGlobalWaitLookupCapDiagnostics(sessFront *sessionpkg.Store, sessionBea } } -func loadWaitBeadsByLabel(store beads.Store) ([]beads.Bead, error) { +func loadWaitsByLabel(store beads.Store) ([]sessionpkg.WaitInfo, error) { all, err := store.List(beads.ListQuery{ Label: waitBeadLabel, Limit: waitLookupLimit + 1, @@ -815,7 +805,7 @@ func loadWaitBeadsByLabel(store beads.Store) ([]beads.Bead, error) { if capped { all = all[:waitLookupLimit] } - result := make([]beads.Bead, 0, len(all)) + result := make([]sessionpkg.WaitInfo, 0, len(all)) for _, item := range all { if item.Status == "closed" { continue @@ -823,7 +813,7 @@ func loadWaitBeadsByLabel(store beads.Store) ([]beads.Bead, error) { if !sessionpkg.IsWaitBead(item) { continue } - result = append(result, item) + result = append(result, sessionpkg.WaitInfoFromBead(item)) } if capped { return result, beads.LookupLimitError{Kind: "wait", Label: waitBeadLabel, Limit: waitLookupLimit} @@ -831,15 +821,15 @@ func loadWaitBeadsByLabel(store beads.Store) ([]beads.Bead, error) { return result, nil } -func loadWaitBeadsForWakeState(sessStore beads.Store, sessionBeads *sessionBeadSnapshot) ([]beads.Bead, error) { +func loadWaitsForWakeState(sessStore beads.Store, sessionBeads *sessionBeadSnapshot) ([]sessionpkg.WaitInfo, error) { // Open sessions get per-session coverage; waits tied only to closed // sessions can fall outside the newest global capped window under // saturation, with cap diagnostics as the operator signal. - waits, seen, err := loadWaitBeadsForOpenSessionsWithSeen(sessStore, sessionBeads) + waits, seen, err := loadWaitsForOpenSessionsWithSeen(sessStore, sessionBeads) if err != nil { return nil, err } - globalWaits, err := loadWaitBeads(sessStore) + globalWaits, err := loadWaits(sessStore) if err != nil { if !isWaitLookupLimitError(err) { return nil, err @@ -857,19 +847,19 @@ func loadWaitBeadsForWakeState(sessStore beads.Store, sessionBeads *sessionBeadS return waits, nil } -func loadWaitBeadsForOpenSessions(sessStore beads.Store, sessionBeads *sessionBeadSnapshot) ([]beads.Bead, error) { - waits, _, err := loadWaitBeadsForOpenSessionsWithSeen(sessStore, sessionBeads) +func loadWaitsForOpenSessions(sessStore beads.Store, sessionBeads *sessionBeadSnapshot) ([]sessionpkg.WaitInfo, error) { + waits, _, err := loadWaitsForOpenSessionsWithSeen(sessStore, sessionBeads) return waits, err } -func loadWaitBeadsForOpenSessionsWithSeen(sessStore beads.Store, sessionBeads *sessionBeadSnapshot) ([]beads.Bead, map[string]bool, error) { +func loadWaitsForOpenSessionsWithSeen(sessStore beads.Store, sessionBeads *sessionBeadSnapshot) ([]sessionpkg.WaitInfo, map[string]bool, error) { seen := map[string]bool{} if sessStore == nil || sessionBeads == nil { return nil, seen, nil } - waits := []beads.Bead(nil) + waits := []sessionpkg.WaitInfo(nil) for _, sessionInfo := range sessionBeads.OpenInfos() { - sessionWaits, err := loadSessionWaitBeads(sessStore, sessionInfo.ID) + sessionWaits, err := loadSessionWaits(sessStore, sessionInfo.ID) if err != nil { if !isWaitLookupLimitError(err) { return nil, seen, err @@ -888,28 +878,21 @@ func loadWaitBeadsForOpenSessionsWithSeen(sessStore beads.Store, sessionBeads *s return waits, seen, nil } -func depsWaitReady(store beads.Store, wait beads.Bead) bool { +func depsWaitReady(store beads.Store, wait sessionpkg.WaitInfo) bool { ready, err := depsWaitReadyDetailed(store, wait) return err == nil && ready } -func depsWaitReadyDetailed(store beads.Store, wait beads.Bead) (bool, error) { +func depsWaitReadyDetailed(store beads.Store, wait sessionpkg.WaitInfo) (bool, error) { return depsWaitReadyDetailedForCity("", store, wait) } -func depsWaitReadyDetailedForCity(cityPath string, store beads.Store, wait beads.Bead) (bool, error) { - rawDepIDs := strings.Split(wait.Metadata["dep_ids"], ",") - depIDs := make([]string, 0, len(rawDepIDs)) - for _, depID := range rawDepIDs { - depID = strings.TrimSpace(depID) - if depID != "" { - depIDs = append(depIDs, depID) - } - } +func depsWaitReadyDetailedForCity(cityPath string, store beads.Store, wait sessionpkg.WaitInfo) (bool, error) { + depIDs := wait.DepIDs if len(depIDs) == 0 { return false, nil } - mode := wait.Metadata["dep_mode"] + mode := wait.DepMode closedCount := 0 foundAny := false var missingErr error @@ -1031,14 +1014,14 @@ func prepareWaitWakeStateForCityWithSnapshot(cityPath string, sessStore beads.Se return nil, err } } - waits, err := loadWaitBeadsForWakeState(sessStore.Store, sessionBeads) + waits, err := loadWaitsForWakeState(sessStore.Store, sessionBeads) if err != nil { return nil, err } readyWaitSet := make(map[string]bool) for _, wait := range waits { - state := wait.Metadata["state"] - sessionID := wait.Metadata["session_id"] + state := wait.State + sessionID := wait.SessionID if sessionID == "" { continue } @@ -1047,7 +1030,7 @@ func prepareWaitWakeStateForCityWithSnapshot(cityPath string, sessStore beads.Se } sessionInfo, ok := sessionBeads.FindInfoByID(sessionID) if !ok { - if wait.Metadata["registered_epoch"] != "" { + if wait.RegisteredEpoch != "" { var found bool sessionInfo, found, err = lookupSessionBeadByIDInfo(sessStore.Store, sessionID) if err != nil { @@ -1060,7 +1043,7 @@ func prepareWaitWakeStateForCityWithSnapshot(cityPath string, sessStore beads.Se continue } } - if epoch := wait.Metadata["registered_epoch"]; epoch != "" && sessionInfo.ContinuationEpoch != "" && epoch != sessionInfo.ContinuationEpoch { + if epoch := wait.RegisteredEpoch; epoch != "" && sessionInfo.ContinuationEpoch != "" && epoch != sessionInfo.ContinuationEpoch { if err := setWaitTerminalState(sessStore.Store, wait.ID, map[string]string{ "state": waitStateCanceled, "canceled_at": now.UTC().Format(time.RFC3339), @@ -1086,7 +1069,7 @@ func prepareWaitWakeStateForCityWithSnapshot(cityPath string, sessStore beads.Se if !ok { continue } - if expiresAt := wait.Metadata["expires_at"]; expiresAt != "" { + if expiresAt := wait.ExpiresAt; expiresAt != "" { if ts, err := time.Parse(time.RFC3339, expiresAt); err == nil && !ts.After(now) { if err := setWaitTerminalState(sessStore, wait.ID, map[string]string{ "state": waitStateExpired, @@ -1116,7 +1099,7 @@ func prepareWaitWakeStateForCityWithSnapshot(cityPath string, sessStore beads.Se readyWaitSet[sessionID] = true continue } - if wait.Metadata["kind"] != "deps" { + if wait.Kind != "deps" { continue } // Dependency beads are WORK class — read them from the work store. @@ -1193,15 +1176,15 @@ func dispatchReadyWaitNudgesWithSnapshot(cityPath string, cfg *config.City, sess return err } } - waits, err := loadWaitBeadsForOpenSessions(sessStore.Store, sessionBeads) + waits, err := loadWaitsForOpenSessions(sessStore.Store, sessionBeads) if err != nil { return err } for _, wait := range waits { - if wait.Metadata["state"] != waitStateReady { + if wait.State != waitStateReady { continue } - sessionID := wait.Metadata["session_id"] + sessionID := wait.SessionID if sessionID == "" { continue } @@ -1227,7 +1210,7 @@ func dispatchReadyWaitNudgesWithSnapshot(cityPath string, cfg *config.City, sess if ok { continue } - message := strings.TrimSpace(wait.Description) + message := strings.TrimSpace(wait.Note) if message == "" { message = "Wait satisfied." } @@ -1235,7 +1218,7 @@ func dispatchReadyWaitNudgesWithSnapshot(cityPath string, cfg *config.City, sess item := newQueuedNudgeWithOptions(waitNudgeAgent(sessionBead), message, "wait", now, queuedNudgeOptions{ ID: nudgeID, SessionID: sessionID, - ContinuationEpoch: wait.Metadata["registered_epoch"], + ContinuationEpoch: wait.RegisteredEpoch, Reference: &nudgeReference{Kind: "bead", ID: wait.ID}, }) if err := enqueueQueuedNudgeWithStore(cityPath, nudges, item); err != nil { @@ -1279,8 +1262,8 @@ func cachedSessionCanReceiveWaitNudge(sessionBead beads.Bead) bool { // terminal state. sessStore is the session coordination-class store for the wait // bead and cap-diagnostic stamp; nudges is the nudges-class store for the shadow // nudge lookup. Identity today (both wrap the same work store). -func finalizeReadyWaitFromNudge(sessStore beads.Store, nudges beads.NudgesStore, wait beads.Bead, now time.Time) (bool, error) { - nudgeID := wait.Metadata["nudge_id"] +func finalizeReadyWaitFromNudge(sessStore beads.Store, nudges beads.NudgesStore, wait sessionpkg.WaitInfo, now time.Time) (bool, error) { + nudgeID := wait.NudgeID if nudgeID == "" { nudgeID = waitNudgeID(wait) } @@ -1290,7 +1273,7 @@ func finalizeReadyWaitFromNudge(sessStore beads.Store, nudges beads.NudgesStore, nudge, ok, err := nudgeFrontDoor(nudges).FindIncludingTerminal(nudgeID) if err != nil { if beads.IsLookupLimitError(err) { - stampWaitLookupCapDiagnostic(sessionFrontDoor(sessStore), wait.Metadata["session_id"], err, now, "ready-wait-finalize-nudge") + stampWaitLookupCapDiagnostic(sessionFrontDoor(sessStore), wait.SessionID, err, now, "ready-wait-finalize-nudge") return false, nil } return false, err @@ -1365,13 +1348,13 @@ func clearSessionWaitHoldIfIdle(sessStore beads.Store, sessionID string) error { } func hasNonTerminalWaits(store beads.Store, sessionID string) (bool, error) { - waits, err := loadSessionWaitBeads(store, sessionID) + waits, err := loadSessionWaits(store, sessionID) if err != nil && !isWaitLookupLimitError(err) { return false, err } capped := err != nil for _, wait := range waits { - if !isWaitTerminal(wait.Metadata["state"]) { + if !isWaitTerminal(wait.State) { return true, nil } } @@ -1386,12 +1369,12 @@ func isWaitTerminal(state string) bool { return sessionpkg.IsWaitTerminalState(state) } -func waitNudgeID(wait beads.Bead) string { - attempt := wait.Metadata["delivery_attempt"] +func waitNudgeID(wait sessionpkg.WaitInfo) string { + attempt := wait.DeliveryAttempt if attempt == "" { attempt = "1" } - epoch := wait.Metadata["registered_epoch"] + epoch := wait.RegisteredEpoch if epoch == "" { epoch = "0" } @@ -1425,12 +1408,15 @@ func setWaitTerminalState(store beads.Store, waitID string, batch map[string]str // coordination-class store for the wait bead and session marker reads; nudges is // the nudges-class store for the delivery-attempt lookup. Identity today. func retryClosedWait(sessStore beads.Store, nudges beads.NudgesStore, wait beads.Bead, now string) (beads.Bead, error) { - nextAttempt, err := nextWaitDeliveryAttempt(nudgeFrontDoor(nudges), wait) + // Reads go through the typed WaitInfo projection; the metadata-clone and + // Create below stay on the raw bead — this is the wait write/serialization edge. + w := sessionpkg.WaitInfoFromBead(wait) + nextAttempt, err := nextWaitDeliveryAttempt(nudgeFrontDoor(nudges), w) if err != nil { return beads.Bead{}, err } if nextAttempt == "" { - nextAttempt = wait.Metadata["delivery_attempt"] + nextAttempt = w.DeliveryAttempt if nextAttempt == "" { nextAttempt = "1" } @@ -1448,7 +1434,7 @@ func retryClosedWait(sessStore beads.Store, nudges beads.NudgesStore, wait beads meta["canceled_at"] = "" meta["created_at"] = now meta["retried_from_wait"] = wait.ID - if sessionID := wait.Metadata["session_id"]; sessionID != "" && sessStore != nil { + if sessionID := w.SessionID; sessionID != "" && sessStore != nil { if markers, err := sessionFrontDoor(sessStore).PersistedMarkers(sessionID); err == nil { if epoch := markers.ContinuationEpoch; epoch != "" { meta["registered_epoch"] = epoch @@ -1467,16 +1453,16 @@ func retryClosedWait(sessStore beads.Store, nudges beads.NudgesStore, wait beads }) } -func nextWaitDeliveryAttempt(front *nudgequeue.Store, wait beads.Bead) (string, error) { - state := wait.Metadata["state"] +func nextWaitDeliveryAttempt(front *nudgequeue.Store, wait sessionpkg.WaitInfo) (string, error) { + state := wait.State if state == waitStatePending || state == waitStateReady { return "", nil } - attempt, err := strconv.Atoi(wait.Metadata["delivery_attempt"]) + attempt, err := strconv.Atoi(wait.DeliveryAttempt) if err != nil || attempt <= 0 { attempt = 1 } - nudgeID := wait.Metadata["nudge_id"] + nudgeID := wait.NudgeID if nudgeID == "" { nudgeID = waitNudgeID(wait) } diff --git a/cmd/gc/cmd_wait_test.go b/cmd/gc/cmd_wait_test.go index 1f586140d1..37d63a85d5 100644 --- a/cmd/gc/cmd_wait_test.go +++ b/cmd/gc/cmd_wait_test.go @@ -12,6 +12,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "sort" "strings" "sync" @@ -323,7 +324,7 @@ func TestWaitJSONEncoderErrorsWriteDiagnostics(t *testing.T) { } stderr.Reset() - if code := writeWaitInspectJSON(failingWriter{}, &stderr, "/city", beads.Bead{}); code != 1 { + if code := writeWaitInspectJSON(failingWriter{}, &stderr, "/city", sessionpkg.WaitInfo{}); code != 1 { t.Fatalf("writeWaitInspectJSON = %d, want 1", code) } if !strings.Contains(stderr.String(), "gc wait inspect: encode JSON: write failed") { @@ -331,6 +332,84 @@ func TestWaitJSONEncoderErrorsWriteDiagnostics(t *testing.T) { } } +// TestWaitJSONFromInfo_MatchesBeadProjection locks the schema_version-1 CLI JSON +// contract byte-for-byte across the WaitInfo refactor: a fully-populated wait +// bead projected through the session codec and mapped to waitJSON must equal the +// hand-written literal the inline waitJSONFromBead previously produced. +func TestWaitJSONFromInfo_MatchesBeadProjection(t *testing.T) { + created := time.Date(2026, 5, 15, 9, 30, 0, 0, time.UTC) + b := beads.Bead{ + ID: "gc-wait-1", + Type: waitBeadType, + Status: "closed", + Title: "wait:worker", + Description: "Continue after review closes.", + CreatedAt: created, + Labels: []string{waitBeadLabel, "session:gc-session"}, + Metadata: map[string]string{ + "session_id": "gc-session", + "session_name": "worker", + "kind": "deps", + "state": waitStateReady, + "dep_ids": "gc-1,gc-2", + "dep_mode": "all", + "registered_epoch": "3", + "delivery_attempt": "2", + "nudge_id": "wait-gc-wait-1-3-2", + }, + } + got := waitJSONFromInfo(sessionpkg.WaitInfoFromBead(b)) + want := waitJSON{ + ID: "gc-wait-1", + SessionID: "gc-session", + SessionName: "worker", + State: waitStateReady, + Kind: "deps", + DepIDs: []string{"gc-1", "gc-2"}, + DepMode: "all", + RegisteredEpoch: "3", + DeliveryAttempt: "2", + NudgeID: "wait-gc-wait-1-3-2", + Note: "Continue after review closes.", + Status: "closed", + CreatedAt: created.UTC().Format(time.RFC3339), + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("waitJSONFromInfo = %#v, want %#v", got, want) + } +} + +// TestWriteWaitDetail_RendersWaitInfo pins the human wait-inspect render, +// including the comma-joined DepIDs on the Deps line. +func TestWriteWaitDetail_RendersWaitInfo(t *testing.T) { + w := sessionpkg.WaitInfo{ + ID: "gc-wait-1", + SessionID: "gc-session", + State: waitStateReady, + Kind: "deps", + DepIDs: []string{"a", "b"}, + DepMode: "all", + RegisteredEpoch: "3", + DeliveryAttempt: "2", + NudgeID: "wait-gc-wait-1-3-2", + Note: "Continue after review closes.", + } + var buf bytes.Buffer + writeWaitDetail(w, &buf) + want := "Wait: gc-wait-1\n" + + "Session: gc-session\n" + + "State: ready\n" + + "Kind: deps\n" + + "Deps: a,b (all)\n" + + "Epoch: 3\n" + + "Attempt: 2\n" + + "Nudge: wait-gc-wait-1-3-2\n" + + "Note: Continue after review closes.\n" + if got := buf.String(); got != want { + t.Fatalf("writeWaitDetail =\n%q\nwant\n%q", got, want) + } +} + func TestWaitJSONSchemasDoNotExposeRawMetadata(t *testing.T) { for _, path := range []string{ filepath.Join("..", "..", "schemas", "wait", "list", "result.schema.json"), @@ -553,9 +632,9 @@ func TestLoadWaitBeadsByLabelUsesBoundedLookup(t *testing.T) { } store := &waitListQueryCaptureStore{Store: mem} - waits, err := loadWaitBeadsByLabel(store) + waits, err := loadWaitsByLabel(store) if err != nil { - t.Fatalf("loadWaitBeadsByLabel: %v", err) + t.Fatalf("loadWaitsByLabel: %v", err) } if len(waits) != 1 { t.Fatalf("wait count = %d, want 1", len(waits)) @@ -583,9 +662,9 @@ func TestLoadWaitBeadsByLabelAllowsExactLookupLimit(t *testing.T) { } } - waits, err := loadWaitBeadsByLabel(mem) + waits, err := loadWaitsByLabel(mem) if err != nil { - t.Fatalf("loadWaitBeadsByLabel: %v", err) + t.Fatalf("loadWaitsByLabel: %v", err) } if len(waits) != waitLookupLimit { t.Fatalf("wait count = %d, want %d", len(waits), waitLookupLimit) @@ -593,9 +672,9 @@ func TestLoadWaitBeadsByLabelAllowsExactLookupLimit(t *testing.T) { } func TestLoadWaitBeadsByLabelReportsLookupLimit(t *testing.T) { - _, err := loadWaitBeadsByLabel(waitLookupLimitStore{Store: beads.NewMemStore()}) + _, err := loadWaitsByLabel(waitLookupLimitStore{Store: beads.NewMemStore()}) if err == nil || !strings.Contains(err.Error(), "wait lookup hit limit") { - t.Fatalf("loadWaitBeadsByLabel error = %v, want wait lookup limit", err) + t.Fatalf("loadWaitsByLabel error = %v, want wait lookup limit", err) } } @@ -1026,7 +1105,7 @@ func TestPrepareWaitWakeState_FinalizesFromNudge(t *testing.T) { if err != nil { t.Fatalf("create wait bead: %v", err) } - nudgeID := waitNudgeID(waitBead) + nudgeID := waitNudgeID(sessionpkg.WaitInfoFromBead(waitBead)) nudge, err := store.Create(beads.Bead{ Type: nudgeBeadType, Title: "nudge:" + nudgeID, @@ -1471,12 +1550,12 @@ func TestDepsWaitReady_IgnoresEmptyDependencyEntries(t *testing.T) { t.Fatalf("close dep bead: %v", err) } - ready := depsWaitReady(store, beads.Bead{ + ready := depsWaitReady(store, sessionpkg.WaitInfoFromBead(beads.Bead{ Metadata: map[string]string{ "dep_ids": dep.ID + ", ,", "dep_mode": "all", }, - }) + })) if !ready { t.Fatal("depsWaitReady = false, want true with only one real closed dependency") } @@ -1496,7 +1575,7 @@ func TestNextWaitDeliveryAttempt_IncrementsAfterTerminalNudge(t *testing.T) { if err != nil { t.Fatalf("create wait bead: %v", err) } - nudgeID := waitNudgeID(wait) + nudgeID := waitNudgeID(sessionpkg.WaitInfoFromBead(wait)) nudge, err := store.Create(beads.Bead{ Type: nudgeBeadType, Title: "nudge:" + nudgeID, @@ -1513,7 +1592,7 @@ func TestNextWaitDeliveryAttempt_IncrementsAfterTerminalNudge(t *testing.T) { t.Fatalf("close nudge bead: %v", err) } - next, err := nextWaitDeliveryAttempt(nudgeFrontDoor(beads.NudgesStore{Store: store}), wait) + next, err := nextWaitDeliveryAttempt(nudgeFrontDoor(beads.NudgesStore{Store: store}), sessionpkg.WaitInfoFromBead(wait)) if err != nil { t.Fatalf("nextWaitDeliveryAttempt: %v", err) } @@ -1552,7 +1631,7 @@ func TestRetryClosedWait_CreatesReplacement(t *testing.T) { if err != nil { t.Fatalf("create wait bead: %v", err) } - nudgeID := waitNudgeID(wait) + nudgeID := waitNudgeID(sessionpkg.WaitInfoFromBead(wait)) nudge, err := store.Create(beads.Bead{ Type: nudgeBeadType, Title: "nudge:" + nudgeID, @@ -1745,7 +1824,7 @@ func TestDispatchReadyWaitNudges_EnqueuesDeterministicNudge(t *testing.T) { if len(pending) != 1 || len(inFlight) != 0 || len(dead) != 0 { t.Fatalf("pending=%d inFlight=%d dead=%d, want 1/0/0", len(pending), len(inFlight), len(dead)) } - wantID := waitNudgeID(waitBead) + wantID := waitNudgeID(sessionpkg.WaitInfoFromBead(waitBead)) if pending[0].ID != wantID { t.Fatalf("queued nudge id = %q, want %q", pending[0].ID, wantID) } @@ -1864,8 +1943,8 @@ func TestDispatchReadyWaitNudges_ProcessesOpenSessionWaitsWithoutGlobalWaitList( if err != nil { t.Fatalf("listQueuedNudges: %v", err) } - if len(pending) != 1 || pending[0].ID != waitNudgeID(waitBead) { - t.Fatalf("pending nudges = %#v, want one wait nudge %q", pending, waitNudgeID(waitBead)) + if len(pending) != 1 || pending[0].ID != waitNudgeID(sessionpkg.WaitInfoFromBead(waitBead)) { + t.Fatalf("pending nudges = %#v, want one wait nudge %q", pending, waitNudgeID(sessionpkg.WaitInfoFromBead(waitBead))) } } @@ -2307,26 +2386,31 @@ func TestCancelWaitsForSessionReturnsNilAfterCappedConvergence(t *testing.T) { func TestLoadSessionWaitBeads_IncludesLegacyWaitType(t *testing.T) { store := beads.NewMemStore() sessionID := "gc-session" - if _, err := store.Create(beads.Bead{ + // loadSessionWaits returns session.WaitInfo, which omits the storage-level + // bead Type. The legacy-type wait still flows through the lookup, so assert + // the created legacy bead is returned by ID (the IsWaitBead legacy-type + // coverage stays enforced by internal/session's IsWaitBead tests). + legacy, err := store.Create(beads.Bead{ Type: sessionpkg.LegacyWaitBeadType, Labels: []string{waitBeadLabel, "session:" + sessionID}, Metadata: map[string]string{ "session_id": sessionID, "state": waitStatePending, }, - }); err != nil { + }) + if err != nil { t.Fatalf("create legacy wait bead: %v", err) } - waits, err := loadSessionWaitBeads(store, sessionID) + waits, err := loadSessionWaits(store, sessionID) if err != nil { - t.Fatalf("loadSessionWaitBeads: %v", err) + t.Fatalf("loadSessionWaits: %v", err) } if len(waits) != 1 { - t.Fatalf("loadSessionWaitBeads returned %d waits, want 1", len(waits)) + t.Fatalf("loadSessionWaits returned %d waits, want 1", len(waits)) } - if waits[0].Type != sessionpkg.LegacyWaitBeadType { - t.Fatalf("wait type = %q, want legacy %q", waits[0].Type, sessionpkg.LegacyWaitBeadType) + if waits[0].ID != legacy.ID { + t.Fatalf("wait ID = %q, want legacy wait %q", waits[0].ID, legacy.ID) } } diff --git a/cmd/gc/session_beads.go b/cmd/gc/session_beads.go index d332713c6e..a51a64c565 100644 --- a/cmd/gc/session_beads.go +++ b/cmd/gc/session_beads.go @@ -948,7 +948,7 @@ func cancelStateAssignedToRetiredSessionBead(store beads.Store, sessionID string if stderr == nil { stderr = io.Discard } - if _, err := session.ListSessionWaitBeads(store, sessionID); beads.IsLookupLimitError(err) { + if _, err := session.ListSessionWaits(store, sessionID); beads.IsLookupLimitError(err) { stampWaitLookupCapDiagnostic(sessionFrontDoor(store), sessionID, err, now, "retired-session-cleanup") } if err := session.CancelWaits(store, sessionID, now); err != nil { diff --git a/internal/coordclass/classify_test.go b/internal/coordclass/classify_test.go index 1cb4c82f5c..437861cef0 100644 --- a/internal/coordclass/classify_test.go +++ b/internal/coordclass/classify_test.go @@ -57,7 +57,7 @@ func TestClassifyGoldenTable(t *testing.T) { // label; it classifies via the gc:wait class signal. {"wait bead with per-entity session label", beads.Bead{Type: "gate", Labels: []string{"gc:wait", "session:gc-7"}}, ClassSessions}, // Federation-correctness guard: the per-entity session: label is NOT a - // class signal. ListSessionWaitBeads queries by Label="session:", which a + // class signal. ListSessionWaits queries by Label="session:", which a // route-by-query adapter would mis-route to the session store — but the // federating Router classifies by the class-level signal (gc:wait/gc:session/ // type=session), so a bead carrying ONLY session: stays ClassWork. This diff --git a/internal/session/waits.go b/internal/session/waits.go index ae5edc611c..d0c4c48848 100644 --- a/internal/session/waits.go +++ b/internal/session/waits.go @@ -85,8 +85,97 @@ func IsWaitBead(b beads.Bead) bool { return sessionID != "" && beadHasLabel(b, "session:"+sessionID) } -// ListSessionWaitBeads returns open durable wait beads for one session. -func ListSessionWaitBeads(store beads.Store, sessionID string) ([]beads.Bead, error) { +// WaitInfo is the typed projection of a durable session wait bead: the domain +// view of a wait that callers read and decide against without touching +// *beads.Bead. It carries only bead-stored facts (metadata keys, description, +// status, created-at, labels), so a wait bead round-trips to the same WaitInfo +// regardless of which backend stored it. +// +// Bead serialization for waits is confined to this file: WaitInfoFromBead is the +// only place the wait-read paths learn these facts come from a bead. The wait +// write paths (metadata batches, retry clones, create) still speak *beads.Bead — +// that is the deliberate serialization edge, mirroring session.Store. +type WaitInfo struct { + // ID is the wait bead ID. + ID string + // SessionID is the session bead ID the wait is registered against (metadata session_id). + SessionID string + // SessionName is the runtime session name recorded at registration (metadata session_name). + SessionName string + // Kind is the wait kind, e.g. "deps" or "probe" (metadata kind). + Kind string + // State is the wait lifecycle state, e.g. "pending"/"ready" (metadata state). + State string + // DepIDs are the dependency bead IDs the wait watches, comma-split and + // trimmed with empties dropped (metadata dep_ids). It is nil when unset. + DepIDs []string + // DepMode is "all" or "any" (metadata dep_mode). + DepMode string + // RegisteredEpoch is the session continuation epoch at registration (metadata registered_epoch). + RegisteredEpoch string + // DeliveryAttempt is the current delivery attempt counter (metadata delivery_attempt). + DeliveryAttempt string + // NudgeID is the shadow wait-nudge ID once dispatched (metadata nudge_id). + NudgeID string + // ExpiresAt is the raw RFC3339 expiry string kept verbatim; consumers parse + // it and tolerate malformed values (metadata expires_at). + ExpiresAt string + // Note is the reminder text delivered when the wait is satisfied (bead Description, untrimmed). + Note string + // Status is the persisted bead status ("open"/"closed"). + Status string + // CreatedAt is the bead creation time. + CreatedAt time.Time + // Labels are the bead labels. + Labels []string +} + +// WaitInfoFromBead projects a durable wait bead onto WaitInfo. It is pure, +// side-effect-free, and backend-invariant: it reads only stored bead fields and +// applies the same key-for-key decoding (and dep_ids split/trim) the wait render +// and decision paths previously performed inline. +func WaitInfoFromBead(b beads.Bead) WaitInfo { + return WaitInfo{ + ID: b.ID, + SessionID: b.Metadata["session_id"], + SessionName: b.Metadata["session_name"], + Kind: b.Metadata["kind"], + State: b.Metadata["state"], + DepIDs: splitWaitDepIDs(b.Metadata["dep_ids"]), + DepMode: b.Metadata["dep_mode"], + RegisteredEpoch: b.Metadata["registered_epoch"], + DeliveryAttempt: b.Metadata["delivery_attempt"], + NudgeID: b.Metadata["nudge_id"], + ExpiresAt: b.Metadata["expires_at"], + Note: b.Description, + Status: b.Status, + CreatedAt: b.CreatedAt, + Labels: b.Labels, + } +} + +// splitWaitDepIDs splits a comma-separated dep_ids value into trimmed, non-empty +// IDs, returning nil for a blank value. It is the confined codec for the wait +// dependency-ID list (formerly cmd/gc's splitWaitIDs). +func splitWaitDepIDs(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + parts := strings.Split(value, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +// ListSessionWaits returns the WaitInfo projection of open durable wait beads for +// one session. No raw beads cross this boundary; the IsWaitBead and session_id +// filters run on the listed beads before projection because this function is the +// codec edge. +func ListSessionWaits(store beads.Store, sessionID string) ([]WaitInfo, error) { if store == nil || sessionID == "" { return nil, nil } @@ -107,7 +196,7 @@ func ListSessionWaitBeads(store beads.Store, sessionID string) ([]beads.Bead, er if capped { waits = waits[:SessionWaitLookupLimit] } - result := make([]beads.Bead, 0, len(waits)) + result := make([]WaitInfo, 0, len(waits)) for _, wait := range waits { if !IsWaitBead(wait) { continue @@ -115,7 +204,7 @@ func ListSessionWaitBeads(store beads.Store, sessionID string) ([]beads.Bead, er if wait.Metadata["session_id"] != sessionID { continue } - result = append(result, wait) + result = append(result, WaitInfoFromBead(wait)) } if capped { return result, beads.LookupLimitError{Kind: "wait", Label: "session:" + sessionID, Limit: SessionWaitLookupLimit} @@ -125,19 +214,18 @@ func ListSessionWaitBeads(store beads.Store, sessionID string) ([]beads.Bead, er // WaitNudgeIDs returns queued nudge IDs for the session's currently open waits. func WaitNudgeIDs(store beads.Store, sessionID string) ([]string, error) { - waits, err := ListSessionWaitBeads(store, sessionID) + waits, err := ListSessionWaits(store, sessionID) if err != nil && !beads.IsLookupLimitError(err) { return nil, err } ids := make([]string, 0, len(waits)) seen := make(map[string]bool, len(waits)) for _, wait := range waits { - nudgeID := wait.Metadata["nudge_id"] - if nudgeID == "" || seen[nudgeID] { + if wait.NudgeID == "" || seen[wait.NudgeID] { continue } - seen[nudgeID] = true - ids = append(ids, nudgeID) + seen[wait.NudgeID] = true + ids = append(ids, wait.NudgeID) } return ids, err } @@ -162,14 +250,14 @@ func ReassignWaits(store beads.Store, oldSessionID, newSessionID string) error { oldLabel := "session:" + oldSessionID newLabel := "session:" + newSessionID for { - waits, err := ListSessionWaitBeads(store, oldSessionID) + waits, err := ListSessionWaits(store, oldSessionID) if err != nil && !beads.IsLookupLimitError(err) { return err } lookupCapped := beads.IsLookupLimitError(err) progressed := 0 for _, wait := range waits { - if IsWaitTerminalState(wait.Metadata["state"]) { + if IsWaitTerminalState(wait.State) { if err := store.Close(wait.ID); err != nil { return fmt.Errorf("closing terminal wait %s for session %s: %w", wait.ID, oldSessionID, err) } @@ -177,7 +265,7 @@ func ReassignWaits(store beads.Store, oldSessionID, newSessionID string) error { continue } labels := []string(nil) - if !beadHasLabel(wait, newLabel) { + if !labelsContain(wait.Labels, newLabel) { labels = []string{newLabel} } if err := store.Update(wait.ID, beads.UpdateOpts{ @@ -256,7 +344,7 @@ func cancelWaitsAndCollectNudgeIDs(store beads.Store, sessionID string, now time "canceled_at": now.UTC().Format(time.RFC3339), } for { - waits, err := ListSessionWaitBeads(store, sessionID) + waits, err := ListSessionWaits(store, sessionID) if err != nil && !beads.IsLookupLimitError(err) { return ids, capped, err } @@ -265,11 +353,11 @@ func cancelWaitsAndCollectNudgeIDs(store beads.Store, sessionID string, now time cancelIDs := make([]string, 0, len(waits)) terminalIDs := make([]string, 0, len(waits)) for _, wait := range waits { - if nudgeID := wait.Metadata["nudge_id"]; nudgeID != "" && !seen[nudgeID] { - seen[nudgeID] = true - ids = append(ids, nudgeID) + if wait.NudgeID != "" && !seen[wait.NudgeID] { + seen[wait.NudgeID] = true + ids = append(ids, wait.NudgeID) } - if IsWaitTerminalState(wait.Metadata["state"]) { + if IsWaitTerminalState(wait.State) { terminalIDs = append(terminalIDs, wait.ID) continue } @@ -302,7 +390,11 @@ func CancelWaits(store beads.Store, sessionID string, now time.Time) error { } func beadHasLabel(b beads.Bead, want string) bool { - for _, label := range b.Labels { + return labelsContain(b.Labels, want) +} + +func labelsContain(labels []string, want string) bool { + for _, label := range labels { if label == want { return true } diff --git a/internal/session/waits_test.go b/internal/session/waits_test.go index 9da2069483..4ad6531d8f 100644 --- a/internal/session/waits_test.go +++ b/internal/session/waits_test.go @@ -3,6 +3,7 @@ package session import ( "errors" "fmt" + "reflect" "strings" "testing" "time" @@ -10,6 +11,101 @@ import ( "github.com/gastownhall/gascity/internal/beads" ) +func TestWaitInfoFromBead_ProjectsAllFields(t *testing.T) { + created := time.Date(2026, 5, 15, 9, 30, 0, 0, time.UTC) + b := beads.Bead{ + ID: "gc-wait-1", + Type: WaitBeadType, + Status: "closed", + Title: "wait:worker", + Description: "Continue after review closes.", + CreatedAt: created, + Labels: []string{WaitBeadLabel, "session:gc-session"}, + Metadata: map[string]string{ + "session_id": "gc-session", + "session_name": "worker", + "kind": "deps", + "state": "ready", + "dep_ids": "gc-1,gc-2", + "dep_mode": "all", + "registered_epoch": "3", + "delivery_attempt": "2", + "nudge_id": "wait-gc-wait-1-3-2", + "expires_at": "2026-05-16T09:30:00Z", + }, + } + got := WaitInfoFromBead(b) + want := WaitInfo{ + ID: "gc-wait-1", + SessionID: "gc-session", + SessionName: "worker", + Kind: "deps", + State: "ready", + DepIDs: []string{"gc-1", "gc-2"}, + DepMode: "all", + RegisteredEpoch: "3", + DeliveryAttempt: "2", + NudgeID: "wait-gc-wait-1-3-2", + ExpiresAt: "2026-05-16T09:30:00Z", + Note: "Continue after review closes.", + Status: "closed", + CreatedAt: created, + Labels: []string{WaitBeadLabel, "session:gc-session"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("WaitInfoFromBead = %#v, want %#v", got, want) + } +} + +func TestWaitInfoFromBead_DepIDsSplitTrimEmpty(t *testing.T) { + cases := []struct { + name string + depIDs string + want []string + }{ + {"trims and drops empties", " a , b ,,c ", []string{"a", "b", "c"}}, + {"empty string", "", nil}, + {"single id", "gc-1", []string{"gc-1"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := WaitInfoFromBead(beads.Bead{Metadata: map[string]string{"dep_ids": tc.depIDs}}).DepIDs + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("DepIDs = %#v, want %#v", got, tc.want) + } + }) + } + if got := WaitInfoFromBead(beads.Bead{}).DepIDs; got != nil { + t.Fatalf("DepIDs for absent dep_ids key = %#v, want nil", got) + } +} + +func TestListSessionWaits_ReturnsProjectedWaitInfo(t *testing.T) { + store := beads.NewMemStore() + created, err := store.Create(beads.Bead{ + Type: WaitBeadType, + Labels: []string{WaitBeadLabel, "session:gc-session"}, + Metadata: map[string]string{ + "session_id": "gc-session", + "state": "ready", + "nudge_id": "wait-nudge", + }, + }) + if err != nil { + t.Fatalf("create wait: %v", err) + } + waits, err := ListSessionWaits(store, "gc-session") + if err != nil { + t.Fatalf("ListSessionWaits: %v", err) + } + if len(waits) != 1 { + t.Fatalf("wait count = %d, want 1", len(waits)) + } + if w := waits[0]; w.ID != created.ID || w.SessionID != "gc-session" || w.State != "ready" || w.NudgeID != "wait-nudge" { + t.Fatalf("WaitInfo = %#v, want id=%s session=gc-session state=ready nudge=wait-nudge", w, created.ID) + } +} + type rejectLegacyWaitTypeQueryStore struct { *beads.MemStore } @@ -144,22 +240,22 @@ func TestWaitNudgeIDs_UsesBoundedDeterministicSessionLookup(t *testing.T) { } } -func TestListSessionWaitBeads_AllowsExactLookupLimit(t *testing.T) { +func TestListSessionWaits_AllowsExactLookupLimit(t *testing.T) { store := &sessionWaitExactLimitStore{Store: beads.NewMemStore()} - waits, err := ListSessionWaitBeads(store, "gc-session") + waits, err := ListSessionWaits(store, "gc-session") if err != nil { - t.Fatalf("ListSessionWaitBeads: %v", err) + t.Fatalf("ListSessionWaits: %v", err) } if len(waits) != SessionWaitLookupLimit { t.Fatalf("wait count = %d, want %d", len(waits), SessionWaitLookupLimit) } } -func TestListSessionWaitBeads_ReportsLimitWithFilteredPartial(t *testing.T) { - waits, err := ListSessionWaitBeads(sessionWaitLimitStore{Store: beads.NewMemStore()}, "gc-session") +func TestListSessionWaits_ReportsLimitWithFilteredPartial(t *testing.T) { + waits, err := ListSessionWaits(sessionWaitLimitStore{Store: beads.NewMemStore()}, "gc-session") if !beads.IsLookupLimitError(err) { - t.Fatalf("ListSessionWaitBeads error = %v, want lookup limit", err) + t.Fatalf("ListSessionWaits error = %v, want lookup limit", err) } if len(waits) != SessionWaitLookupLimit { t.Fatalf("wait count = %d, want filtered partial count %d", len(waits), SessionWaitLookupLimit) From e4c6382ab03c5fc7d34454d6fac1448f5c436946 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 8 Jul 2026 20:35:41 -0700 Subject: [PATCH 022/225] refactor(session): retire dead legacy wake helpers + raw-bead ghost twins (#4055) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Retires the dead/legacy raw-session wake helpers now that production wake decisions run entirely on the typed `ComputeAwakeSet` path. Net **+230 / −494**. - **Slice 1 (CLI gate):** adds `TestSessionReason_MultiReasonColumnCharacterization` pinning the exact `gc session` REASON column (`session,config,attached`, `wait`, `,pin` suffix, `-` collapse). It passes on unchanged code and stays green through every slice — proving the CLI output is byte-identical. - **Slice 2:** deletes the test-only drain wrappers `advanceSessionDrains` / `advanceSessionDrainsWithSessions` (zero production callers — the reconciler calls `advanceSessionDrainsWithSessionsTraced` directly) and migrates the 11 test call sites to feed the traced core **explicit** `wakeEvals` encoding each test's original premise. No assertion weakened. - **Slice 3:** deletes 7 dead functions (`computeWakeEvaluations`, `capWakeConfigByDemand`, `applyDependencyWakeReasons`, `removeWakeReason`, `preferredDependencySessions`, `compareDependencyCandidate`, `hasDependencyWakeRoot`) and the `WakeDependency` constant. **Keeps** `wakeReasons`/`evaluateWakeReasons` (the CLI REASON-column display helpers — multi-reason, so they cannot collapse to single-reason `ComputeAwakeSet`) and `containsWakeReason`, with an accurate scope comment. - **Slice 4:** deletes 3 raw-bead ghost twins (`scaleCheckPartialSessionPreservable`, `scaleCheckPartialSessionRetainable`, `isPendingPoolCreate`); the production `*Info` siblings survive. ## Behavior preservation Production wake/sleep decisions are unchanged — every deleted function was already dead in production (main-branch comments documented "never fires in production"). Both rg zero-hit checks confirm the deleted symbols are gone (only `*Info` forms remain). The two deviations are comment/debug-trace-string only (renaming stale references to the deleted `advanceSessionDrains` name to avoid dangling ghosts). ## Verification - `go build ./...`, `go vet ./...`, `gofmt` clean - Characterization gate + drain suite (`TestAdvanceSessionDrains|CompleteDrain|DrainTracker|CancelSessionDrain`) + `TestWakeReasons|EvaluateWakeReasons|SessionClassifierInfoEquivalence` green - Bounded blast-radius run (Session/Wake/Drain/Pool/Reconcil/DesiredState/ScaleCheck/Classifier/Sleep/Idle/Heal/Cancel) `ok 182s` - Fable adversarial review: approve — independently re-verified dead-code, preserved test premises, and the characterization pin > Note for CI: use the sharded `make test-cmd-gc-process-parallel` target; monolithic > `go test ./cmd/gc/` times out on an unrelated network-hanging init test in this environment. First slice of the session-class adoption stack (O1 → O2 → O4). Refs `ga-6aaj6q`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/gc/build_desired_state.go | 46 ++-- ...esired_state_legacy_bound_recovery_test.go | 2 +- cmd/gc/build_desired_state_test.go | 2 +- cmd/gc/cmd_session_test.go | 125 +++++++++++ cmd/gc/compute_awake_bridge.go | 2 +- cmd/gc/session_circuit_breaker.go | 2 +- cmd/gc/session_classifier_info_equiv_test.go | 31 ++- cmd/gc/session_reconcile.go | 200 +----------------- cmd/gc/session_reconcile_test.go | 73 ------- cmd/gc/session_reconciler.go | 6 +- cmd/gc/session_reconciler_test.go | 42 ---- cmd/gc/session_sleep_test.go | 40 ++-- cmd/gc/session_types.go | 2 - cmd/gc/session_wake.go | 75 +------ cmd/gc/session_wake_test.go | 76 ++++--- ...ndency-aware-bounded-parallel-lifecycle.md | 4 +- engdocs/design/idle-session-sleep.md | 4 +- engdocs/design/session-reconciler-tracing.md | 2 +- 18 files changed, 235 insertions(+), 499 deletions(-) diff --git a/cmd/gc/build_desired_state.go b/cmd/gc/build_desired_state.go index 39857fd77b..48bc835050 100644 --- a/cmd/gc/build_desired_state.go +++ b/cmd/gc/build_desired_state.go @@ -1813,21 +1813,11 @@ func retainScaleCheckPartialPoolDesired(cfg *config.City, counts map[string]int, return counts } -// Preserve dormant affected-template beads during transient scale_check -// failures, but do not count them as awake demand. Sessions that are already -// mid-drain or past-drain (draining/drained/archived) are not preserved so a -// partial read cannot interrupt an in-progress drain lifecycle. -func scaleCheckPartialSessionPreservable(b beads.Bead) bool { - switch strings.TrimSpace(b.Metadata["state"]) { - case "", "active", "awake", "start-pending", "creating", "asleep", "stopped", "suspended", "quarantined": - return true - default: - return isPendingPoolCreate(b) - } -} - -// scaleCheckPartialSessionPreservableInfo is the session.Info mirror of -// scaleCheckPartialSessionPreservable: it reads the raw state metadata +// scaleCheckPartialSessionPreservableInfo preserves dormant affected-template +// beads during transient scale_check failures, but does not count them as awake +// demand. Sessions that are already mid-drain or past-drain +// (draining/drained/archived) are not preserved so a partial read cannot +// interrupt an in-progress drain lifecycle. It reads the raw state metadata // (Info.MetadataState) and delegates the in-flight-create default case to // isPendingPoolCreateInfo. func scaleCheckPartialSessionPreservableInfo(i session.Info) bool { @@ -1839,20 +1829,11 @@ func scaleCheckPartialSessionPreservableInfo(i session.Info) bool { } } -func scaleCheckPartialSessionRetainable(b beads.Bead) bool { - switch strings.TrimSpace(b.Metadata["state"]) { - case "active", "awake": - return true - default: - // A fresh in-flight create that still holds an active pending_create_claim - // lease counts as retained capacity. Stale creates (lease expired/cleared) - // return false so they stop inflating the desired count. - return isPendingPoolCreate(b) - } -} - -// scaleCheckPartialSessionRetainableInfo is the session.Info mirror of -// scaleCheckPartialSessionRetainable: it reads the raw state metadata +// scaleCheckPartialSessionRetainableInfo counts active/awake affected-template +// beads as retained demand during transient scale_check failures. A fresh +// in-flight create that still holds an active pending_create_claim lease also +// counts as retained capacity; stale creates (lease expired/cleared) do not, so +// they stop inflating the desired count. It reads the raw state metadata // (Info.MetadataState) and delegates the in-flight-create case to // isPendingPoolCreateInfo. func scaleCheckPartialSessionRetainableInfo(i session.Info) bool { @@ -2302,11 +2283,8 @@ func discoverSessionBeadsWithRoots( return roots } -func isPendingPoolCreate(b beads.Bead) bool { - return isPoolManagedSessionBead(b) && strings.TrimSpace(b.Metadata["pending_create_claim"]) == boolMetadata(true) -} - -// isPendingPoolCreateInfo is the session.Info mirror of isPendingPoolCreate. +// isPendingPoolCreateInfo reports whether a pool-managed session is an in-flight +// create still holding an active pending_create_claim lease. func isPendingPoolCreateInfo(i session.Info) bool { return isPoolManagedSessionInfo(i) && i.PendingCreateClaim } diff --git a/cmd/gc/build_desired_state_legacy_bound_recovery_test.go b/cmd/gc/build_desired_state_legacy_bound_recovery_test.go index 7536ac6dda..2916bae6be 100644 --- a/cmd/gc/build_desired_state_legacy_bound_recovery_test.go +++ b/cmd/gc/build_desired_state_legacy_bound_recovery_test.go @@ -420,7 +420,7 @@ func TestRetainScaleCheckPartialPoolDesiredNormalizesLegacyBoundTemplate(t *test } // TestRetainScaleCheckPartialPoolDesired_InFlightCreatingBeadRetained confirms that -// scaleCheckPartialSessionRetainable retains creating beads that hold an active +// scaleCheckPartialSessionRetainableInfo retains creating beads that hold an active // pending_create_claim lease, while stale creates (lease cleared/expired) are dropped. // This is acceptance criterion #4 from ga-4qbgqf.1: after the retainable narrowing // that removes "start-pending" and "creating" from the explicit case list, diff --git a/cmd/gc/build_desired_state_test.go b/cmd/gc/build_desired_state_test.go index 7c59746858..a5b0b880ef 100644 --- a/cmd/gc/build_desired_state_test.go +++ b/cmd/gc/build_desired_state_test.go @@ -11229,7 +11229,7 @@ func TestBuildDesiredState_ScaleCheckPartialPoolBlocksNewCreates(t *testing.T) { // Criterion #6 (ga-4qbgqf.3): fresh in-flight creates (pending_create_claim=true) // are retained in desired state and in the retained count during a partial tick. - // poolPartialAlive is true via isPendingPoolCreate, so the narrow guard keeps them. + // poolPartialAlive is true via isPendingPoolCreateInfo, so the narrow guard keeps them. t.Run("fresh pending_create_claim creating bead retained during partial tick", func(t *testing.T) { partialStore := &controllerDemandPartialStore{MemStore: beads.NewMemStore()} freshCreate := beads.Bead{ diff --git a/cmd/gc/cmd_session_test.go b/cmd/gc/cmd_session_test.go index 580a9921a0..e0210d3b10 100644 --- a/cmd/gc/cmd_session_test.go +++ b/cmd/gc/cmd_session_test.go @@ -1785,6 +1785,131 @@ func TestSessionReason_SuppressesWakeReasonsForHistoricalArchivedBead(t *testing } } +// TestSessionReason_MultiReasonColumnCharacterization pins the exact +// comma-joined REASON cell that `gc session` emits today. It is a +// byte-identical gate: the wake-helper cleanup (ga-6aaj6q) retires the legacy +// drain/dependency wake path but must not change what the CLI displays, which +// still runs through evaluateWakeReasons. If a literal ever drifts, this test +// fails and forces a deliberate decision rather than a silent regression. +func TestSessionReason_MultiReasonColumnCharacterization(t *testing.T) { + const agentName = "worker" + const sessionName = "reason-worker" + cfg := &config.City{ + Agents: []config.Agent{{Name: agentName}}, + } + + newBead := func(state string, extra map[string]string) beads.Bead { + md := map[string]string{ + "template": agentName, + "session_name": sessionName, + "state": state, + } + for k, v := range extra { + md[k] = v + } + return beads.Bead{ID: "gc-1", Status: "open", Metadata: md} + } + newInfo := func(state session.State) session.Info { + return session.Info{ + ID: "gc-1", + Template: agentName, + State: state, + SessionName: sessionName, + } + } + attachingProvider := func(attached bool) runtime.Provider { + return &attachmentCachingProvider{ + Provider: runtime.NewFake(), + cache: buildAttachmentCache([]session.Info{newInfo(session.StateActive)}, func(session.Info) (bool, error) { + return attached, nil + }), + } + } + + type matchMode int + const ( + matchExact matchMode = iota + matchContains + matchSuffix + ) + + tests := []struct { + name string + bead beads.Bead + info session.Info + provider runtime.Provider + poolDesired map[string]int + readyWait map[string]bool + mode matchMode + want string + }{ + { + name: "active pool session attached emits ordered multi-reason cell", + bead: newBead("active", nil), + info: newInfo(session.StateActive), + provider: attachingProvider(true), + poolDesired: map[string]int{agentName: 1}, + mode: matchExact, + want: "session,config,attached", + }, + { + name: "asleep session with ready wait shows wait reason", + bead: newBead("asleep", nil), + info: newInfo(session.StateAsleep), + provider: runtime.NewFake(), + readyWait: map[string]bool{"gc-1": true}, + mode: matchContains, + want: string(WakeWait), + }, + { + name: "pin_awake appends pin as the final reason", + bead: newBead("active", map[string]string{"pin_awake": "true"}), + info: newInfo(session.StateActive), + provider: attachingProvider(false), + poolDesired: map[string]int{agentName: 1}, + mode: matchSuffix, + want: "," + string(WakePin), + }, + { + name: "no reasons collapses to dash", + bead: newBead("asleep", nil), + info: newInfo(session.StateAsleep), + provider: runtime.NewFake(), + mode: matchExact, + want: "-", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + before := cloneSessionReasonMetadata(tt.bead.Metadata) + got := sessionReason( + tt.info, + map[string]beads.Bead{tt.bead.ID: tt.bead}, + cfg, + tt.provider, + tt.poolDesired, + tt.readyWait, + ) + switch tt.mode { + case matchExact: + if got != tt.want { + t.Fatalf("sessionReason = %q, want %q", got, tt.want) + } + case matchContains: + if !strings.Contains(got, tt.want) { + t.Fatalf("sessionReason = %q, want it to contain %q", got, tt.want) + } + case matchSuffix: + if !strings.HasSuffix(got, tt.want) { + t.Fatalf("sessionReason = %q, want it to end with %q", got, tt.want) + } + } + assertStringMapEqual(t, tt.bead.Metadata, before) + }) + } +} + func TestAttachmentCachingProvider_DelegatesSleepCapability(t *testing.T) { provider := &attachmentAwareProvider{ Fake: runtime.NewFake(), diff --git a/cmd/gc/compute_awake_bridge.go b/cmd/gc/compute_awake_bridge.go index ce574584f1..23600dabbf 100644 --- a/cmd/gc/compute_awake_bridge.go +++ b/cmd/gc/compute_awake_bridge.go @@ -222,7 +222,7 @@ func shouldProbeAttachmentForAwakeInput(info session.Info, alive bool, cfg *conf } // awakeSetToWakeEvals converts ComputeAwakeSet output to wakeEvaluation map -// for compatibility with advanceSessionDrainsWithSessions. +// for compatibility with advanceSessionDrainsWithSessionsTraced. func awakeSetToWakeEvals(decisions map[string]AwakeDecision, sessionBeads []AwakeSessionBead) map[string]wakeEvaluation { evals := make(map[string]wakeEvaluation, len(decisions)) for _, bead := range sessionBeads { diff --git a/cmd/gc/session_circuit_breaker.go b/cmd/gc/session_circuit_breaker.go index 8e5cb76891..1f2a8aff1b 100644 --- a/cmd/gc/session_circuit_breaker.go +++ b/cmd/gc/session_circuit_breaker.go @@ -875,7 +875,7 @@ func computeNamedSessionProgressSignatures( // Read the identity/name/alias resolver keys through the typed Info // projection instead of cracking sb.Metadata inline. This scan runs in // Phase 0.5 before the reconciler's coherent infoByID snapshot exists, so - // it projects per bead (the same shape advanceSessionDrains uses); the + // it projects per bead (the same shape advanceSessionDrainsWithSessionsTraced uses); the // projection is pure, so it is byte-identical to the raw reads. info := session.InfoFromPersistedBead(sb) identity := strings.TrimSpace(info.ConfiguredNamedIdentity) diff --git a/cmd/gc/session_classifier_info_equiv_test.go b/cmd/gc/session_classifier_info_equiv_test.go index 7643743ed7..919048d255 100644 --- a/cmd/gc/session_classifier_info_equiv_test.go +++ b/cmd/gc/session_classifier_info_equiv_test.go @@ -675,23 +675,20 @@ func TestSessionClassifierInfoEquivalence(t *testing.T) { bead func(beads.Bead) bool info func(session.Info) bool }{ - "isPoolManagedSessionBead": {isPoolManagedSessionBead, isPoolManagedSessionInfo}, - "isEphemeralSessionBead": {isEphemeralSessionBead, isEphemeralSessionInfo}, - "isManualSessionBead": {isManualSessionBead, isManualSessionInfo}, - "isNamedSessionBead": {isNamedSessionBead, isNamedSessionInfo}, - "isDrainedSessionBead": {isDrainedSessionBead, isDrainedSessionInfo}, - "isFailedCreateSessionBead": {isFailedCreateSessionBead, isFailedCreateSessionInfo}, - "shouldRollbackPendingCreate": {func(b beads.Bead) bool { return shouldRollbackPendingCreate(&b) }, shouldRollbackPendingCreateInfo}, - "isPendingPoolCreate": {isPendingPoolCreate, isPendingPoolCreateInfo}, - "isStaleCreating": {isStaleCreating, isStaleCreatingInfo}, - "isKnownState": {isKnownState, isKnownStateInfo}, - "isPoolSessionSlotFreeable": {isPoolSessionSlotFreeable, isPoolSessionSlotFreeableInfo}, - "beadOwnsPoolSessionName": {beadOwnsPoolSessionName, infoOwnsPoolSessionName}, - "sessionHasProviderTerminalError": {sessionHasProviderTerminalError, sessionHasProviderTerminalErrorInfo}, - "poolSessionConsumesNewDemand": {poolSessionConsumesNewDemand, poolSessionConsumesNewDemandInfo}, - "scaleCheckPartialSessionRetainable": {scaleCheckPartialSessionRetainable, scaleCheckPartialSessionRetainableInfo}, - "scaleCheckPartialSessionPreservable": {scaleCheckPartialSessionPreservable, scaleCheckPartialSessionPreservableInfo}, - "isDrainAckStopPending": {isDrainAckStopPending, isDrainAckStopPendingInfo}, + "isPoolManagedSessionBead": {isPoolManagedSessionBead, isPoolManagedSessionInfo}, + "isEphemeralSessionBead": {isEphemeralSessionBead, isEphemeralSessionInfo}, + "isManualSessionBead": {isManualSessionBead, isManualSessionInfo}, + "isNamedSessionBead": {isNamedSessionBead, isNamedSessionInfo}, + "isDrainedSessionBead": {isDrainedSessionBead, isDrainedSessionInfo}, + "isFailedCreateSessionBead": {isFailedCreateSessionBead, isFailedCreateSessionInfo}, + "shouldRollbackPendingCreate": {func(b beads.Bead) bool { return shouldRollbackPendingCreate(&b) }, shouldRollbackPendingCreateInfo}, + "isStaleCreating": {isStaleCreating, isStaleCreatingInfo}, + "isKnownState": {isKnownState, isKnownStateInfo}, + "isPoolSessionSlotFreeable": {isPoolSessionSlotFreeable, isPoolSessionSlotFreeableInfo}, + "beadOwnsPoolSessionName": {beadOwnsPoolSessionName, infoOwnsPoolSessionName}, + "sessionHasProviderTerminalError": {sessionHasProviderTerminalError, sessionHasProviderTerminalErrorInfo}, + "poolSessionConsumesNewDemand": {poolSessionConsumesNewDemand, poolSessionConsumesNewDemandInfo}, + "isDrainAckStopPending": {isDrainAckStopPending, isDrainAckStopPendingInfo}, } // Agent-dependent classifiers. A bare pool agent (no instance-expansion, no diff --git a/cmd/gc/session_reconcile.go b/cmd/gc/session_reconcile.go index 8b34af72d1..2fbb5e47a6 100644 --- a/cmd/gc/session_reconcile.go +++ b/cmd/gc/session_reconcile.go @@ -47,19 +47,12 @@ const ( sessionProviderTerminalErrorAtKey = "provider_terminal_error_at" ) -// Deprecated: evaluateWakeReasons and wakeReasons are legacy functions -// superseded by ComputeAwakeSet (compute_awake_set.go). The production -// reconciler at session_reconciler.go:438 uses ComputeAwakeSet → -// awakeSetToWakeEvals for all wake/drain decisions. These functions are -// only called by computeWakeEvaluations (used as a nil-guard fallback -// in advanceSessionDrains, which never fires because the reconciler -// always passes non-nil wakeEvals) and by legacy tests. -// -// DO NOT add new wake logic here — it will have NO EFFECT on production -// behavior. All wake/sleep changes must go through ComputeAwakeSet. -// -// TODO: Remove these functions and migrate remaining tests to -// ComputeAwakeSet. Tracked as tech debt. +// wakeReasons and evaluateWakeReasons are the CLI `gc session` REASON-column +// display helpers ONLY. They compute the multi-reason, comma-joined cell shown +// to operators; their sole production caller is sessionReason in cmd_session.go. +// Production wake/sleep decisions come exclusively from ComputeAwakeSet +// (compute_awake_set.go) via awakeSetToWakeEvals — do NOT add wake logic here, +// it has no effect on reconciler behavior. func wakeReasons( session beads.Bead, @@ -256,176 +249,6 @@ func sessionMetadataStateInfo(i sessionpkg.Info) string { } } -func computeWakeEvaluations( - sessions []beads.Bead, - cfg *config.City, - sp runtime.Provider, - poolDesired map[string]int, - workSet map[string]bool, - readyWaitSet map[string]bool, - clk clock.Clock, -) map[string]wakeEvaluation { - evals := make(map[string]wakeEvaluation, len(sessions)) - for _, session := range sessions { - evals[session.ID] = evaluateWakeReasons(session, cfg, sp, poolDesired, workSet, readyWaitSet, clk) - } - applyDependencyWakeReasons(sessions, cfg, evals) - capWakeConfigByDemand(sessions, cfg, evals, poolDesired) - return evals -} - -// capWakeConfigByDemand removes WakeConfig from excess sessions so that -// at most poolDesired[template] sessions get WakeConfig per template. -// -// Priority: sessions that are already alive or have resume-tier reasons -// (WakeSession, WakeAttached) keep their WakeConfig. Excess asleep -// sessions lose it. Sessions in creating/awake state that don't have -// assigned work count against the budget (they're "in-flight new" -// sessions that haven't claimed yet). -func capWakeConfigByDemand(sessions []beads.Bead, cfg *config.City, evals map[string]wakeEvaluation, poolDesired map[string]int) { - // Group sessions by template and count how many already need to be awake. - type templateBudget struct { - desired int - active int // creating/awake — already consuming a slot - wakeIDs []string // sessions with WakeConfig that are asleep - } - budgets := make(map[string]*templateBudget) - - for _, session := range sessions { - eval, ok := evals[session.ID] - if !ok { - continue - } - if !containsWakeReason(eval.Reasons, WakeConfig) { - continue - } - // Named sessions with mode=always are not pool-managed — skip capping. - if isNamedSessionBead(session) && namedSessionMode(session) == "always" { - continue - } - // Manual sessions (user-created via API/UI) bypass pool demand — they - // should stay alive until explicitly closed. - if isManualSessionBead(session) { - continue - } - template := normalizedSessionTemplate(session, cfg) - if template == "" { - continue - } - - b := budgets[template] - if b == nil { - b = &templateBudget{desired: poolDesired[template]} - budgets[template] = b - } - - state := sessionMetadataState(session) - switch state { - case "active", "start-pending", "creating": - // Already running or starting — counts against desired. - b.active++ - default: - // Asleep — candidate for wake, subject to budget. - b.wakeIDs = append(b.wakeIDs, session.ID) - } - } - - // For each template, only allow enough asleep→wake transitions to - // fill the gap between active and desired. - for _, b := range budgets { - slotsAvailable := b.desired - b.active - if slotsAvailable < 0 { - slotsAvailable = 0 - } - // Keep the first slotsAvailable asleep sessions, strip WakeConfig from the rest. - for i, id := range b.wakeIDs { - if i >= slotsAvailable { - eval := evals[id] - eval.Reasons = removeWakeReason(eval.Reasons, WakeConfig) - evals[id] = eval - } - } - } -} - -func removeWakeReason(reasons []WakeReason, remove WakeReason) []WakeReason { - var result []WakeReason - for _, r := range reasons { - if r != remove { - result = append(result, r) - } - } - return result -} - -func applyDependencyWakeReasons(sessions []beads.Bead, cfg *config.City, evals map[string]wakeEvaluation) { - if cfg == nil || len(evals) == 0 { - return - } - roots := make(map[string]bool) - for _, session := range sessions { - eval, ok := evals[session.ID] - if !ok || !hasDependencyWakeRoot(eval.Reasons) { - continue - } - template := normalizedSessionTemplate(session, cfg) - if template != "" { - roots[template] = true - } - } - if len(roots) == 0 { - return - } - preferred := preferredDependencySessions(sessions, cfg) - visited := make(map[string]bool) - var visit func(template string) - visit = func(template string) { - if template == "" || visited[template] { - return - } - visited[template] = true - agent := findAgentByTemplate(cfg, template) - if agent == nil { - return - } - for _, dep := range agent.DependsOn { - if session, ok := preferred[dep]; ok { - eval := evals[session.ID] - if session.Metadata["held_until"] == "" && session.Metadata["quarantined_until"] == "" && !containsWakeReason(eval.Reasons, WakeDependency) { - eval.Reasons = append(eval.Reasons, WakeDependency) - evals[session.ID] = eval - } - } - visit(dep) - } - } - for template := range roots { - visit(template) - } -} - -func preferredDependencySessions(sessions []beads.Bead, cfg *config.City) map[string]beads.Bead { - preferred := make(map[string]beads.Bead) - for _, session := range sessions { - if isDrainedSessionBead(session) { - continue - } - template := normalizedSessionTemplate(session, cfg) - if template == "" { - continue - } - existing, ok := preferred[template] - if !ok || compareDependencyCandidate(session, existing) < 0 { - preferred[template] = session - } - } - return preferred -} - -func compareDependencyCandidate(a, b beads.Bead) int { - return strings.Compare(a.Metadata["session_name"], b.Metadata["session_name"]) -} - func containsWakeReason(reasons []WakeReason, want WakeReason) bool { for _, reason := range reasons { if reason == want { @@ -435,17 +258,6 @@ func containsWakeReason(reasons []WakeReason, want WakeReason) bool { return false } -func hasDependencyWakeRoot(reasons []WakeReason) bool { - return containsWakeReason(reasons, WakeConfig) || - containsWakeReason(reasons, WakeWork) || - containsWakeReason(reasons, WakeWait) || - containsWakeReason(reasons, WakeCreate) || - containsWakeReason(reasons, WakeSession) || - containsWakeReason(reasons, WakeAttached) || - containsWakeReason(reasons, WakePending) || - containsWakeReason(reasons, WakePin) -} - // computeWorkSet runs legacy controller-side work_query commands and returns // the set of template names that have pending work. The current CityRuntime // demand snapshot keeps WorkSet empty and uses assigned-work scans plus diff --git a/cmd/gc/session_reconcile_test.go b/cmd/gc/session_reconcile_test.go index d4f8148670..7617431d49 100644 --- a/cmd/gc/session_reconcile_test.go +++ b/cmd/gc/session_reconcile_test.go @@ -1462,79 +1462,6 @@ func TestSessionIsQuarantined(t *testing.T) { } } -func TestCapWakeConfigByDemand(t *testing.T) { - cfg := &config.City{ - Agents: []config.Agent{ - {Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(10)}, - }, - } - poolDesired := map[string]int{"worker": 2} - - // 5 asleep sessions, all get WakeConfig from evaluateWakeReasons. - // But desired is 2, so only 2 should keep WakeConfig. - sessions := make([]beads.Bead, 5) - for i := range sessions { - sessions[i] = makeBead(fmt.Sprintf("s%d", i), map[string]string{ - "template": "worker", - "session_name": fmt.Sprintf("worker-%d", i), - "state": "asleep", - }) - } - - evals := computeWakeEvaluations(sessions, cfg, nil, poolDesired, nil, nil, &clock.Fake{Time: time.Now()}) - - wakeCount := 0 - for _, eval := range evals { - if containsWakeReason(eval.Reasons, WakeConfig) { - wakeCount++ - } - } - if wakeCount != 2 { - t.Errorf("WakeConfig count = %d, want 2 (poolDesired)", wakeCount) - } -} - -func TestCapWakeConfigByDemand_ActiveCountsAgainstBudget(t *testing.T) { - cfg := &config.City{ - Agents: []config.Agent{ - {Name: "worker", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(10)}, - }, - } - poolDesired := map[string]int{"worker": 3} - - // 1 active (creating), 4 asleep. Desired is 3. - // Active counts against budget: 3 - 1 = 2 asleep should wake. - sessions := []beads.Bead{ - makeBead("s0", map[string]string{ - "template": "worker", "session_name": "worker-0", "state": "creating", - }), - makeBead("s1", map[string]string{ - "template": "worker", "session_name": "worker-1", "state": "asleep", - }), - makeBead("s2", map[string]string{ - "template": "worker", "session_name": "worker-2", "state": "asleep", - }), - makeBead("s3", map[string]string{ - "template": "worker", "session_name": "worker-3", "state": "asleep", - }), - makeBead("s4", map[string]string{ - "template": "worker", "session_name": "worker-4", "state": "asleep", - }), - } - - evals := computeWakeEvaluations(sessions, cfg, nil, poolDesired, nil, nil, &clock.Fake{Time: time.Now()}) - - asleepWakes := 0 - for _, s := range sessions { - if s.Metadata["state"] == "asleep" && containsWakeReason(evals[s.ID].Reasons, WakeConfig) { - asleepWakes++ - } - } - if asleepWakes != 2 { - t.Errorf("asleep sessions with WakeConfig = %d, want 2 (desired 3 minus 1 active)", asleepWakes) - } -} - func TestIsPoolExcess(t *testing.T) { cfg := &config.City{ Agents: []config.Agent{ diff --git a/cmd/gc/session_reconciler.go b/cmd/gc/session_reconciler.go index 7ba96573e4..4bffc8a687 100644 --- a/cmd/gc/session_reconciler.go +++ b/cmd/gc/session_reconciler.go @@ -623,9 +623,9 @@ func finalizeDrainAckStopPendingSessions( finalized := 0 for i := range sessions { session := &sessions[i] - // Boundary per-bead projection (same pattern as the advanceSessionDrains - // wrappers): this non-reconciler pass loads its own []beads.Bead, so it - // projects Info here and feeds the drain-ack helpers off it. + // Boundary per-bead projection (same pattern as the drain scan): this + // non-reconciler pass loads its own []beads.Bead, so it projects Info here + // and feeds the drain-ack helpers off it. info := sessionpkg.InfoFromPersistedBead(*session) if !isDrainAckStopPendingInfo(info) { continue diff --git a/cmd/gc/session_reconciler_test.go b/cmd/gc/session_reconciler_test.go index abd25efbb4..937b0b94ac 100644 --- a/cmd/gc/session_reconciler_test.go +++ b/cmd/gc/session_reconciler_test.go @@ -10462,48 +10462,6 @@ func TestReconcileSessionBeads_ClosesOrphanedFailedCreateAndFreesSlot(t *testing } } -// TODO(pool-consolidation): This test validates that poolDesired gates wake -// decisions. Needs updating when pool_slot is removed — the slot-based gate -// will be replaced with count-based ordering. -func TestPoolDesiredLimitsWakeWork(t *testing.T) { - t.Skip("blocked on pool_slot removal") - env := newReconcilerTestEnv() - env.cfg = &config.City{ - Agents: []config.Agent{ - {Name: "claude", MinActiveSessions: intPtr(0), MaxActiveSessions: intPtr(5)}, - }, - } - // 3 sessions exist and are running, but demand (poolDesired) is only 1. - // Don't add to desiredState — we're testing poolDesired gating only. - var sessions []beads.Bead - for i := 1; i <= 3; i++ { - name := fmt.Sprintf("claude-%d", i) - s := env.createSessionBead(name, "claude") - env.setSessionMetadata(&s, map[string]string{ - "state": "awake", - "pool_slot": fmt.Sprintf("%d", i), - }) - sessions = append(sessions, s) - } - - // poolDesired=1: only 1 session should stay awake. - poolDesired := map[string]int{"claude": 1} - evalInput := make([]beads.Bead, len(sessions)) - copy(evalInput, sessions) - evals := computeWakeEvaluations(evalInput, env.cfg, env.sp, poolDesired, - map[string]bool{"claude": true}, nil, env.clk) - - wakeCount := 0 - for _, eval := range evals { - if len(eval.Reasons) > 0 { - wakeCount++ - } - } - if wakeCount != 1 { - t.Errorf("wakeCount = %d, want 1 (only slot 1 within poolDesired=1)", wakeCount) - } -} - // PR #209 -- skipped for now. Drained beads don't block capacity (all // selection paths skip them). Closing would break gc attach on drained // sessions. Tracked as a future cleanup task. diff --git a/cmd/gc/session_sleep_test.go b/cmd/gc/session_sleep_test.go index 0b1c2faf7e..08410011be 100644 --- a/cmd/gc/session_sleep_test.go +++ b/cmd/gc/session_sleep_test.go @@ -1154,7 +1154,7 @@ func TestReconcileSessionBeads_AsleepSingletonsDoNotWakeViaScaleCheck(t *testing } } -func TestComputeWakeEvaluations_KeepWarmDoesNotPropagateDependencies(t *testing.T) { +func TestEvaluateWakeReasons_KeepWarmForDetachedInteractive(t *testing.T) { cfg := &config.City{ SessionSleep: config.SessionSleepConfig{ InteractiveResume: "60s", @@ -1165,25 +1165,14 @@ func TestComputeWakeEvaluations_KeepWarmDoesNotPropagateDependencies(t *testing. }, } now := time.Now().UTC() - sessions := []beads.Bead{ - makeBead("db-bead", map[string]string{ - "template": "db", - "session_name": "db", - }), - makeBead("api-bead", map[string]string{ - "template": "api", - "session_name": "api", - "detached_at": now.Add(-30 * time.Second).Format(time.RFC3339), - }), - } - evals := computeWakeEvaluations(sessions, cfg, runtime.NewFake(), nil, nil, nil, &clock.Fake{Time: now}) - dbEval := evals["db-bead"] - if containsWakeReason(dbEval.Reasons, WakeDependency) { - t.Fatalf("db reasons = %v, did not want WakeDependency from keep-warm wake", dbEval.Reasons) - } - apiEval := evals["api-bead"] - if !containsWakeReason(apiEval.Reasons, WakeKeepWarm) { - t.Fatalf("api reasons = %v, want WakeKeepWarm", apiEval.Reasons) + apiBead := makeBead("api-bead", map[string]string{ + "template": "api", + "session_name": "api", + "detached_at": now.Add(-30 * time.Second).Format(time.RFC3339), + }) + eval := evaluateWakeReasons(apiBead, cfg, runtime.NewFake(), nil, nil, nil, &clock.Fake{Time: now}) + if !containsWakeReason(eval.Reasons, WakeKeepWarm) { + t.Fatalf("api reasons = %v, want WakeKeepWarm for a recently detached interactive session", eval.Reasons) } } @@ -1283,25 +1272,22 @@ func TestAdvanceSessionDrainsWithSessions_UsesProvidedWakeEvaluations(t *testing t.Fatalf("Start: %v", err) } - advanceSessionDrainsWithSessions( + advanceSessionDrainsWithSessionsTraced( dt, sp, nil, - func(id string) *beads.Bead { + infoLookupFromBeadLookup(func(id string) *beads.Bead { if id == bead.ID { return &bead } return nil - }, - []beads.Bead{bead}, + }), map[string]wakeEvaluation{ bead.ID: {Reasons: []WakeReason{WakeWork}}, }, &config.City{}, - nil, - nil, - nil, &clock.Fake{Time: now}, + nil, ) if got := dt.get(bead.ID); got != nil { diff --git a/cmd/gc/session_types.go b/cmd/gc/session_types.go index a4175aee04..d211bd00d2 100644 --- a/cmd/gc/session_types.go +++ b/cmd/gc/session_types.go @@ -34,8 +34,6 @@ const ( WakePending WakeReason = "pending" // WakePin means pin_awake is set as a durable explicit wake reason. WakePin WakeReason = "pin" - // WakeDependency means another awake session depends on this template. - WakeDependency WakeReason = "dependency" ) // ExecSpec defines a validated command for process creation. diff --git a/cmd/gc/session_wake.go b/cmd/gc/session_wake.go index b0cf71fb84..07434f6dc6 100644 --- a/cmd/gc/session_wake.go +++ b/cmd/gc/session_wake.go @@ -142,7 +142,7 @@ func validateWorkDir(dir string) error { } // beginSessionDrain initiates an async drain. Returns immediately. -// The drainTracker stores in-memory state; advanceSessionDrains progresses it. +// The drainTracker stores in-memory state; advanceSessionDrainsWithSessionsTraced progresses it. // // Returns true when this call enqueued a new drain (a state transition) and // false when a drain was already enqueued for this session (no-op). Callers @@ -151,7 +151,7 @@ func validateWorkDir(dir string) error { // reconciler tick for the life of a stuck drain. // // The interrupt signal (Ctrl-C) is NOT sent immediately. It is deferred to -// the next reconciler tick via advanceSessionDrains. This gives the drain +// the next reconciler tick via advanceSessionDrainsWithSessionsTraced. This gives the drain // one full tick to be canceled (e.g., if the session was falsely orphaned // due to a transient store failure) before any signal reaches the process. // Without this, a single bad tick can interrupt a working agent mid-tool-call. @@ -172,7 +172,7 @@ func beginSessionDrain( // form it backs. func beginSessionDrainInfo( info sessions.Info, - _ runtime.Provider, // kept for caller compatibility; interrupt deferred to advanceSessionDrains + _ runtime.Provider, // kept for caller compatibility; interrupt deferred to advanceSessionDrainsWithSessionsTraced dt *drainTracker, reason string, clk clock.Clock, @@ -435,67 +435,6 @@ func cancelRecoveredDrainForAssignedWork(session beads.Bead, sp runtime.Provider return true } -// advanceSessionDrains checks all in-progress drains. Called once per tick. -// -//nolint:unparam // workSet is nil in the drain path; WakeWork flows via ComputeAwakeSet instead -func advanceSessionDrains( - dt *drainTracker, - sp runtime.Provider, - store beads.Store, - sessionLookup func(id string) *beads.Bead, - cfg *config.City, - poolDesired map[string]int, - workSet map[string]bool, - readyWaitSet map[string]bool, - clk clock.Clock, -) { - var sessions []beads.Bead - for id := range dt.all() { - if session := sessionLookup(id); session != nil { - sessions = append(sessions, *session) - } - } - advanceSessionDrainsWithSessions(dt, sp, store, sessionLookup, sessions, nil, cfg, poolDesired, workSet, readyWaitSet, clk) -} - -func advanceSessionDrainsWithSessions( - dt *drainTracker, - sp runtime.Provider, - store beads.Store, - sessionLookup func(id string) *beads.Bead, - sessions []beads.Bead, - wakeEvals map[string]wakeEvaluation, - cfg *config.City, - poolDesired map[string]int, - workSet map[string]bool, - readyWaitSet map[string]bool, - clk clock.Clock, -) { - // Non-reconciler drain entry points (and their tests) still carry raw beads. - // Derive the wake evaluations from them here when the caller supplied none — - // the traced core requires a non-nil wakeEvals map (Step 5d moved this fallback - // off the prod core; computeWakeEvaluations/evaluateWakeReasons stay for the - // CLI wake column and these wrappers). - if wakeEvals == nil { - wakeEvals = computeWakeEvaluations(sessions, cfg, sp, poolDesired, workSet, readyWaitSet, clk) - } - advanceSessionDrainsWithSessionsTraced(dt, sp, store, infoLookupFromBeadLookup(sessionLookup), wakeEvals, cfg, clk, nil) -} - -// infoLookupFromBeadLookup adapts a raw *beads.Bead lookup to the typed Info -// lookup the drain scan consumes. Used by the non-reconciler drain entry points -// (and their tests), which still carry raw beads; the reconciler builds its Info -// lookup directly from the coherent infoByID snapshot instead. -func infoLookupFromBeadLookup(sessionLookup func(id string) *beads.Bead) func(id string) (sessions.Info, bool) { - return func(id string) (sessions.Info, bool) { - b := sessionLookup(id) - if b == nil { - return sessions.Info{}, false - } - return sessions.InfoFromPersistedBead(*b), true - } -} - func advanceSessionDrainsWithSessionsTraced( dt *drainTracker, sp runtime.Provider, @@ -507,8 +446,8 @@ func advanceSessionDrainsWithSessionsTraced( trace *sessionReconcilerTraceCycle, ) { // wakeEvals is required. The reconciler builds it from the coherent infoByID - // snapshot; the non-reconciler wrappers derive it via computeWakeEvaluations - // from their raw beads before calling in. Step 5d dropped the raw-bead + // snapshot via ComputeAwakeSet -> awakeSetToWakeEvals; tests supply explicit + // wakeEvals encoding the premise they exercise. Step 5d dropped the raw-bead // wakeEvals==nil fallback and its now-unused sessionBeads/poolDesired/workSet/ // readyWaitSet inputs from this prod core — the scan runs entirely off infoLookup. // Session front door constructed once from the same store; nil when store is @@ -621,7 +560,7 @@ func advanceSessionDrainsWithSessionsTraced( // SIGTERM/SIGKILL — no Ctrl-C keystroke injection into the pane. if !ds.ackSet { if os.Getenv("GC_TMUX_TRACE") == "1" { - log.Printf("[DRAIN-TRACE] advanceSessionDrains: setting GC_DRAIN_ACK session=%s reason=%s", name, ds.reason) + log.Printf("[DRAIN-TRACE] advanceSessionDrainsWithSessionsTraced: setting GC_DRAIN_ACK session=%s reason=%s", name, ds.reason) } err := setReconcilerDrainAckMetadata(sp, name, ds) if err == nil { @@ -691,7 +630,7 @@ func advanceSessionDrainsWithSessionsTraced( // session. It reads only the typed Info (id + raw wake_mode); the raw-bead // mirror the reconciler used to keep is dropped. Nothing reads a drained // session's metadata later in the tick — the awake scan runs before -// advanceSessionDrains, and completeDrain is always followed by dt.remove + +// advanceSessionDrainsWithSessionsTraced, and completeDrain is always followed by dt.remove + // continue — so the store write is the sole observable effect (all completeDrain // tests assert on store.Get). With no store there is nothing to persist. func completeDrain(info sessions.Info, sessFront *sessions.Store, ds *drainState, clk clock.Clock) { diff --git a/cmd/gc/session_wake_test.go b/cmd/gc/session_wake_test.go index 22c168ab5c..0b41b09cba 100644 --- a/cmd/gc/session_wake_test.go +++ b/cmd/gc/session_wake_test.go @@ -809,6 +809,20 @@ func TestCancelSessionDrain_NonCancelableReason(t *testing.T) { } } +// infoLookupFromBeadLookup adapts a raw *beads.Bead lookup to the typed Info +// lookup the drain scan consumes. The drain tests still carry raw beads; the +// reconciler builds its Info lookup directly from the coherent infoByID +// snapshot instead. +func infoLookupFromBeadLookup(sessionLookup func(id string) *beads.Bead) func(id string) (sessionpkg.Info, bool) { + return func(id string) (sessionpkg.Info, bool) { + b := sessionLookup(id) + if b == nil { + return sessionpkg.Info{}, false + } + return sessionpkg.InfoFromPersistedBead(*b), true + } +} + func TestAdvanceSessionDrains_ProcessExited(t *testing.T) { now := time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC) clk := &clock.Fake{Time: now} @@ -837,10 +851,10 @@ func TestAdvanceSessionDrains_ProcessExited(t *testing.T) { cfg := &config.City{} - advanceSessionDrains(dt, sp, store, func(id string) *beads.Bead { + advanceSessionDrainsWithSessionsTraced(dt, sp, store, infoLookupFromBeadLookup(func(id string) *beads.Bead { got, _ := store.Get(id) return &got - }, cfg, map[string]int{"worker": 1}, nil, nil, clk) + }), map[string]wakeEvaluation{}, cfg, clk, nil) // Drain should be cleaned up. if dt.get(b.ID) != nil { @@ -891,10 +905,10 @@ func TestAdvanceSessionDrains_Timeout(t *testing.T) { cfg := &config.City{} - advanceSessionDrains(dt, sp, store, func(id string) *beads.Bead { + advanceSessionDrainsWithSessionsTraced(dt, sp, store, infoLookupFromBeadLookup(func(id string) *beads.Bead { got, _ := store.Get(id) return &got - }, cfg, map[string]int{}, nil, nil, clk) + }), map[string]wakeEvaluation{}, cfg, clk, nil) // Should have force-stopped. if sp.IsRunning("test-session") { @@ -938,10 +952,12 @@ func TestAdvanceSessionDrains_WakeReasonsReappear(t *testing.T) { // A desired pool slot still has WakeConfig, which should cancel the drain. cfg := &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(1), MaxActiveSessions: intPtr(1)}}} - advanceSessionDrains(dt, sp, store, func(id string) *beads.Bead { + advanceSessionDrainsWithSessionsTraced(dt, sp, store, infoLookupFromBeadLookup(func(id string) *beads.Bead { got, _ := store.Get(id) return &got - }, cfg, map[string]int{"worker": 1}, nil, nil, clk) + }), map[string]wakeEvaluation{ + b.ID: {Reasons: []WakeReason{WakeConfig}}, + }, cfg, clk, nil) // Drain should be canceled — wake reasons reappeared. if dt.get(b.ID) != nil { @@ -993,10 +1009,12 @@ func TestAdvanceSessionDrains_DeferredInterrupt_CanceledBeforeSignal(t *testing. // Simulate next tick: wake reasons reappear (store recovered) → cancel drain. cfg := &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(1), MaxActiveSessions: intPtr(1)}}} - advanceSessionDrains(dt, sp, store, func(id string) *beads.Bead { + advanceSessionDrainsWithSessionsTraced(dt, sp, store, infoLookupFromBeadLookup(func(id string) *beads.Bead { got, _ := store.Get(id) return &got - }, cfg, map[string]int{"worker": 1}, nil, nil, clk) + }), map[string]wakeEvaluation{ + b.ID: {Reasons: []WakeReason{WakeConfig}}, + }, cfg, clk, nil) // Orphaned drains are non-cancelable because the session is leaving the // desired set. The drain survives and receives its deferred signal. @@ -1059,15 +1077,14 @@ func TestAdvanceSessionDrains_OrphanedDrainCanceledForAssignedWork(t *testing.T) generation: 3, ackSet: true, }) - advanceSessionDrainsWithSessions( + advanceSessionDrainsWithSessionsTraced( dt, sp, store, - func(id string) *beads.Bead { + infoLookupFromBeadLookup(func(id string) *beads.Bead { got, _ := store.Get(id) return &got - }, - []beads.Bead{b}, + }), map[string]wakeEvaluation{ b.ID: { Reasons: []WakeReason{WakeWork}, @@ -1075,10 +1092,8 @@ func TestAdvanceSessionDrains_OrphanedDrainCanceledForAssignedWork(t *testing.T) }, }, &config.City{Agents: []config.Agent{{Name: "worker"}}}, - nil, - nil, - nil, clk, + nil, ) if ds := dt.get(b.ID); ds != nil { @@ -1134,15 +1149,14 @@ func TestAdvanceSessionDrains_NoWakeDrainCanceledForAssignedWork(t *testing.T) { generation: 3, ackSet: true, }) - advanceSessionDrainsWithSessions( + advanceSessionDrainsWithSessionsTraced( dt, sp, store, - func(id string) *beads.Bead { + infoLookupFromBeadLookup(func(id string) *beads.Bead { got, _ := store.Get(id) return &got - }, - []beads.Bead{b}, + }), map[string]wakeEvaluation{ b.ID: { Reasons: []WakeReason{WakeWork}, @@ -1150,10 +1164,8 @@ func TestAdvanceSessionDrains_NoWakeDrainCanceledForAssignedWork(t *testing.T) { }, }, &config.City{Agents: []config.Agent{{Name: "worker"}}}, - nil, - nil, - nil, clk, + nil, ) if ds := dt.get(b.ID); ds != nil { @@ -1229,10 +1241,12 @@ func TestAdvanceSessionDrains_DeferredInterrupt_CancelableNoSignal(t *testing.T) // Simulate next tick: wake reasons reappear → cancel drain before interrupt. cfg := &config.City{Agents: []config.Agent{{Name: "worker", MinActiveSessions: intPtr(1), MaxActiveSessions: intPtr(1)}}} - advanceSessionDrains(dt, sp, store, func(id string) *beads.Bead { + advanceSessionDrainsWithSessionsTraced(dt, sp, store, infoLookupFromBeadLookup(func(id string) *beads.Bead { got, _ := store.Get(id) return &got - }, cfg, map[string]int{"worker": 1}, nil, nil, clk) + }), map[string]wakeEvaluation{ + b.ID: {Reasons: []WakeReason{WakeConfig}}, + }, cfg, clk, nil) // Drain should be canceled — no-wake-reason is cancelable. if dt.get(b.ID) != nil { @@ -1324,10 +1338,10 @@ func TestAdvanceSessionDrains_TimeoutTokenMismatch(t *testing.T) { cfg := &config.City{} - advanceSessionDrains(dt, sp, store, func(id string) *beads.Bead { + advanceSessionDrainsWithSessionsTraced(dt, sp, store, infoLookupFromBeadLookup(func(id string) *beads.Bead { got, _ := store.Get(id) return &got - }, cfg, map[string]int{}, nil, nil, clk) + }), map[string]wakeEvaluation{}, cfg, clk, nil) // Drain should be canceled (stale token), session still running. if dt.get(b.ID) != nil { @@ -1483,10 +1497,12 @@ func TestAdvanceSessionDrains_CancelsForReadyWait(t *testing.T) { generation: 3, }) - advanceSessionDrains(dt, sp, store, func(id string) *beads.Bead { + advanceSessionDrainsWithSessionsTraced(dt, sp, store, infoLookupFromBeadLookup(func(id string) *beads.Bead { got, _ := store.Get(id) return &got - }, &config.City{}, map[string]int{}, nil, map[string]bool{b.ID: true}, clk) + }), map[string]wakeEvaluation{ + b.ID: {Reasons: []WakeReason{WakeWait}}, + }, &config.City{}, clk, nil) if dt.get(b.ID) != nil { t.Fatal("drain should be canceled when a wait becomes ready mid-drain") @@ -1525,10 +1541,10 @@ func TestAdvanceSessionDrains_ClearsIdleProbeOnCompletion(t *testing.T) { t.Fatal("expected idle probe to start") } - advanceSessionDrains(dt, sp, store, func(id string) *beads.Bead { + advanceSessionDrainsWithSessionsTraced(dt, sp, store, infoLookupFromBeadLookup(func(id string) *beads.Bead { got, _ := store.Get(id) return &got - }, &config.City{}, map[string]int{}, nil, nil, clk) + }), map[string]wakeEvaluation{}, &config.City{}, clk, nil) if dt.get(b.ID) != nil { t.Fatal("drain should be removed after completion") diff --git a/engdocs/design/dependency-aware-bounded-parallel-lifecycle.md b/engdocs/design/dependency-aware-bounded-parallel-lifecycle.md index bde1a9e7e9..8beb892006 100644 --- a/engdocs/design/dependency-aware-bounded-parallel-lifecycle.md +++ b/engdocs/design/dependency-aware-bounded-parallel-lifecycle.md @@ -453,8 +453,8 @@ the bounds if needed. ## Open Questions -1. Whether `advanceSessionDrains` should later parallelize timed-out - `verifiedStop` calls. This proposal leaves that path unchanged. +1. Whether `advanceSessionDrainsWithSessionsTraced` should later parallelize + timed-out `verifiedStop` calls. This proposal leaves that path unchanged. 2. Whether provider conformance tests should explicitly require concurrent `Start`/`Stop` safety across distinct session names. 3. Whether wake budget should eventually become per-layer instead of diff --git a/engdocs/design/idle-session-sleep.md b/engdocs/design/idle-session-sleep.md index dfa74151c9..103c0a2a01 100644 --- a/engdocs/design/idle-session-sleep.md +++ b/engdocs/design/idle-session-sleep.md @@ -509,8 +509,8 @@ probe, abort the idle-sleep attempt for that tick. non-probe work, so no new idle probe is started once that reserve would be consumed - remaining candidates are skipped until the next tick -- `advanceSessionDrains` always runs even when the tick admits zero new - probes +- `advanceSessionDrainsWithSessionsTraced` always runs even when the tick + admits zero new probes If the provider does not support `WaitForIdle`, the controller may still sleep based on timed inactivity only when the session capability is diff --git a/engdocs/design/session-reconciler-tracing.md b/engdocs/design/session-reconciler-tracing.md index 78513cfe78..0f1ed1611f 100644 --- a/engdocs/design/session-reconciler-tracing.md +++ b/engdocs/design/session-reconciler-tracing.md @@ -233,7 +233,7 @@ than idealized semantic phases: 4. pool demand / cap calculation 5. `beadReconcileTick` and `reconcileSessionBeads` 6. `executePlannedStarts` -7. `advanceSessionDrainsWithSessions` +7. `advanceSessionDrainsWithSessionsTraced` 8. tick finalization Records may still interleave logically. Flush groups are ordering and From 55a7e33078d2cf6baab05e6972ddc0a124291682 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 8 Jul 2026 21:18:15 -0700 Subject: [PATCH 023/225] refactor(orders,api): route order feed/history through OrderRun + convergence GateOutput (#4050) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Routes the order feed/history read edges through the typed `orders.OrderRun` decode instead of cracking tracking-bead labels/`Status` inline, and gives the `convergence.gate_*` exec-gate vocabulary its **own** `internal/convergence` projection rather than folding it into orders. - `internal/orders/store.go` — new `RunFromTrackingBead`, `RunOutcome.IsExec/Display`, `OrderRun.State`, `Store.ListTracking`, `Store.LatestOpenRun` - `internal/convergence/gate_output.go` (new) — `GateOutput` + `GateOutputFromMetadata`/ `HasOutput`/`CombinedOutput`; convergence owns the `convergence.gate_*` keys - `internal/api/orders_feed.go`, `huma_handlers_orders.go`, `handler_orders.go` — decode `OrderRun`s + `GateOutput`; deleted `orderTrackingStatus`, `orderTrackingScopedName`, `orderLabelsContain*`, `lastRunOutcomeFromLabels`, `orderRunHasOutput` (6 raw `convergence.gate_*` reads removed) ## Ownership note `convergence.gate_*` is a distinct exec-gate vocabulary, so it lives in `internal/convergence`, **not** `orders.OrderRun` — the order tracking bead does not own those keys. ## Behavior preservation Display status and scoped-name derivation are byte-identical to the deleted inline logic; the store reads mirror the prior raw queries (including `LatestOpenRun`'s deliberate `IncludeClosed` omission, pin-tested). `Display()` was verified byte-equivalent to `lastRunOutcomeFromLabels` for every outcome label set a production writer emits; a doc comment on `outcomeFromLabels` now records the single-outcome-family-per-bead invariant this relies on. ## Verification - `go build ./...`, `go vet` clean; `gofmt` clean - `go test ./internal/orders ./internal/convergence ./internal/api` green; `go test ./cmd/gc -run Order` green - `TestOpenAPISpecInSync` green (no wire drift; no generated/dashboard file touched) - TDD: decode/projection tests written first; API outcome table ported verbatim into the orders package - Fable adversarial review: approve (behavior byte-identical on all reachable inputs) ## Follow-ups (pre-existing, out of scope) `huma_handlers_orders.go` history-fetch still hand-builds the `order-run:` label / raw `ListQuery`, and `cmd/gc/order_dispatch.go markTrackingFailure` writes the `{wisp,wisp-failed}` pair via raw `store.Update` — both are pre-existing sites for a later `orders.Store` history-read / `SetOutcomeWithCursor` slice. Part of the raw-bead-leak cleanup epic. Refs `ga-wp0309`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- internal/api/handler_orders.go | 14 -- internal/api/handler_orders_test.go | 25 --- internal/api/huma_handlers_orders.go | 44 ++--- internal/api/orders_feed.go | 103 +++-------- internal/api/orders_feed_test.go | 46 +++-- internal/convergence/gate_output.go | 56 ++++++ internal/convergence/gate_output_test.go | 65 +++++++ internal/orders/store.go | 124 +++++++++++++ internal/orders/store_test.go | 211 +++++++++++++++++++++++ 9 files changed, 522 insertions(+), 166 deletions(-) create mode 100644 internal/convergence/gate_output.go create mode 100644 internal/convergence/gate_output_test.go diff --git a/internal/api/handler_orders.go b/internal/api/handler_orders.go index 134b56fc8b..dcb06fa0bf 100644 --- a/internal/api/handler_orders.go +++ b/internal/api/handler_orders.go @@ -94,17 +94,3 @@ func toOrderResponse(a orders.Order) orderResponse { Env: a.Env, } } - -// lastRunOutcomeFromLabels extracts the run outcome from bead labels. -func lastRunOutcomeFromLabels(labels []string) string { - switch { - case orderLabelsContainExecFailure(labels), orderLabelsContainTriggerEnvFailure(labels), containsString(labels, "wisp-failed"): - return "failed" - case containsString(labels, "wisp-canceled"): - return "canceled" - case containsString(labels, "exec"), containsString(labels, "wisp"): - return "success" - default: - return "" - } -} diff --git a/internal/api/handler_orders_test.go b/internal/api/handler_orders_test.go index 36c448ace4..d23f24aa62 100644 --- a/internal/api/handler_orders_test.go +++ b/internal/api/handler_orders_test.go @@ -517,31 +517,6 @@ func TestHandleOrderCheckRunsConditionByDefault(t *testing.T) { } } -func TestLastRunOutcomeFromLabelsPrioritizesTerminalLabels(t *testing.T) { - tests := []struct { - name string - labels []string - want string - }{ - {name: "wisp failed dominates success", labels: []string{"wisp", "wisp-failed"}, want: "failed"}, - {name: "failed alone", labels: []string{"wisp-failed"}, want: "failed"}, - {name: "exec failed dominates success", labels: []string{"exec", "exec-failed"}, want: "failed"}, - {name: "exec env failed is failed", labels: []string{"exec-env-failed"}, want: "failed"}, - {name: "trigger env failed is failed", labels: []string{"trigger-env-failed"}, want: "failed"}, - {name: "canceled dominates success", labels: []string{"wisp", "wisp-canceled"}, want: "canceled"}, - {name: "success fallback", labels: []string{"exec"}, want: "success"}, - {name: "unknown", labels: []string{"order-tracking"}, want: ""}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if got := lastRunOutcomeFromLabels(tc.labels); got != tc.want { - t.Fatalf("lastRunOutcomeFromLabels(%v) = %q, want %q", tc.labels, got, tc.want) - } - }) - } -} - func TestHandleOrdersFeedIgnoresUnrelatedStoreListFailures(t *testing.T) { fs := newFakeState(t) fs.stores["alpha"] = failListStore{Store: beads.NewMemStore()} diff --git a/internal/api/huma_handlers_orders.go b/internal/api/huma_handlers_orders.go index 3ea853312b..7380e1c60d 100644 --- a/internal/api/huma_handlers_orders.go +++ b/internal/api/huma_handlers_orders.go @@ -12,6 +12,7 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/gastownhall/gascity/internal/beadmeta" "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/convergence" "github.com/gastownhall/gascity/internal/events" "github.com/gastownhall/gascity/internal/orders" ) @@ -101,9 +102,10 @@ func (s *Server) humaHandleOrderCheck(_ context.Context, input *OrderCheckInput) cr.LastRun = &ts } if len(history) > 0 { - outcome := lastRunOutcomeFromLabels(history[0].bead.Labels) - if outcome != "" { - cr.LastRunOutcome = &outcome + if run, ok := orders.RunFromTrackingBead(history[0].bead); ok { + if outcome := run.Outcome.Display(); outcome != "" { + cr.LastRunOutcome = &outcome + } } } checks = append(checks, cr) @@ -254,16 +256,14 @@ func (s *Server) humaHandleOrderHistory(_ context.Context, input *OrderHistoryIn CaptureOutput: auto != nil && auto.IsExec(), } - if b.Metadata != nil { - if v, ok := b.Metadata["convergence.gate_duration_ms"]; ok && v != "" { - entry.DurationMs = &v - } - if v, ok := b.Metadata["convergence.gate_exit_code"]; ok && v != "" { - entry.ExitCode = &v - } + gate := convergence.GateOutputFromMetadata(b.Metadata) + if gate.DurationMs != "" { + entry.DurationMs = &gate.DurationMs } - - entry.HasOutput = entry.CaptureOutput || orderRunHasOutput(b) + if gate.ExitCode != "" { + entry.ExitCode = &gate.ExitCode + } + entry.HasOutput = entry.CaptureOutput || gate.HasOutput() entries = append(entries, entry) if len(entries) >= limit { @@ -278,13 +278,6 @@ func (s *Server) humaHandleOrderHistory(_ context.Context, input *OrderHistoryIn return out, nil } -func orderRunHasOutput(b beads.Bead) bool { - if b.Metadata == nil { - return false - } - return b.Metadata["convergence.gate_stdout"] != "" || b.Metadata["convergence.gate_stderr"] != "" -} - // orderHistoryEntry is a single entry in the order history response. type orderHistoryEntry struct { BeadID string `json:"bead_id"` @@ -328,18 +321,7 @@ func (s *Server) humaHandleOrderHistoryDetail(_ context.Context, input *OrderHis } b := result.bead - output := "" - if b.Metadata != nil { - if stdout := b.Metadata["convergence.gate_stdout"]; stdout != "" { - output = stdout - } - if stderr := b.Metadata["convergence.gate_stderr"]; stderr != "" { - if output != "" { - output += "\n" - } - output += stderr - } - } + output := convergence.GateOutputFromMetadata(b.Metadata).CombinedOutput() return &struct { Body orderHistoryDetailResponse diff --git a/internal/api/orders_feed.go b/internal/api/orders_feed.go index c504c65ae3..65ddca43ea 100644 --- a/internal/api/orders_feed.go +++ b/internal/api/orders_feed.go @@ -314,11 +314,8 @@ func buildOrderRunFeedItems(state State, requestedScopeKind, requestedScopeRef s if info.store == nil { continue } - results, err := info.store.List(beads.ListQuery{ - Label: "order-tracking", - Sort: beads.SortCreatedDesc, - TierMode: beads.TierBoth, - }) + front := orders.NewStore(beads.OrdersStore{Store: info.store}) + runs, err := front.ListTracking() if err != nil { if requestedScopeErr == nil && info.scopeKind == requestedScopeKind && info.scopeRef == requestedScopeRef { requestedScopeErr = err @@ -331,32 +328,28 @@ func buildOrderRunFeedItems(state State, requestedScopeKind, requestedScopeRef s continue } - for _, bead := range results { - scopedName := orderTrackingScopedName(bead) - if scopedName == "" { - continue - } - scopeKind, scopeRef := orderTrackingScope(scopedName, cityScopeRef) + for _, run := range runs { + scopeKind, scopeRef := orderTrackingScope(run.Scoped, cityScopeRef) if !includeAllForCity && (scopeKind != requestedScopeKind || scopeRef != requestedScopeRef) { continue } - updatedAt := orderTrackingUpdatedAt(info.store, bead, scopedName) - orderDef, ok := orderByScopedName[scopedName] - title := orderTrackingTitle(scopedName, orderDef, ok) - target := orderTrackingTarget(orderDef, ok, bead) - itemType := orderTrackingType(orderDef, ok, bead) + updatedAt := orderTrackingUpdatedAt(front, run) + orderDef, ok := orderByScopedName[run.Scoped] + title := orderTrackingTitle(run.Scoped, orderDef, ok) + target := orderTrackingTarget(orderDef, ok, run) + itemType := orderTrackingType(orderDef, ok, run) item := monitorFeedItemResponse{ - ID: "order:" + info.ref + ":" + bead.ID, + ID: "order:" + info.ref + ":" + run.ID, Type: itemType, - Status: normalizeMonitorStatus(orderTrackingStatus(bead)), + Status: normalizeMonitorStatus(run.State()), Title: title, ScopeKind: scopeKind, ScopeRef: scopeRef, Target: target, - StartedAt: bead.CreatedAt.Format(time.RFC3339Nano), + StartedAt: run.CreatedAt.Format(time.RFC3339Nano), UpdatedAt: updatedAt.Format(time.RFC3339Nano), - BeadID: bead.ID, + BeadID: run.ID, StoreRef: info.ref, DetailAvailable: ok && orderDef.IsExec(), RunDetailAvailable: ok && orderDef.IsExec(), @@ -376,27 +369,18 @@ func buildOrderRunFeedItems(state State, requestedScopeKind, requestedScopeRef s }, nil } -func orderTrackingUpdatedAt(store beads.Store, tracking beads.Bead, scopedName string) time.Time { - updatedAt := tracking.CreatedAt - if store == nil || strings.TrimSpace(scopedName) == "" { - return updatedAt - } - - runs, err := store.List(beads.ListQuery{ - Label: "order-run:" + scopedName, - Limit: 1, - Sort: beads.SortCreatedDesc, - TierMode: beads.TierBoth, - }) - if err != nil && len(runs) == 0 { - orderFeedLogf("api: order feed update lookup failed for %s bead %s: %v", scopedName, tracking.ID, err) +func orderTrackingUpdatedAt(front *orders.Store, run orders.OrderRun) time.Time { + updatedAt := run.CreatedAt + latest, found, err := front.LatestOpenRun(run.Scoped) + if err != nil && !found { + orderFeedLogf("api: order feed update lookup failed for %s bead %s: %v", run.Scoped, run.ID, err) return updatedAt } if err != nil { - orderFeedLogf("api: order feed update lookup partially failed for %s bead %s: %v", scopedName, tracking.ID, err) + orderFeedLogf("api: order feed update lookup partially failed for %s bead %s: %v", run.Scoped, run.ID, err) } - if len(runs) > 0 && runs[0].CreatedAt.After(updatedAt) { - updatedAt = runs[0].CreatedAt + if found && latest.CreatedAt.After(updatedAt) { + updatedAt = latest.CreatedAt } return updatedAt } @@ -468,15 +452,6 @@ func aggregateWorkflowRunStatus(root beads.Bead, beadsForRun []beads.Bead) strin return best } -func orderTrackingScopedName(bead beads.Bead) string { - for _, label := range bead.Labels { - if scopedName, ok := strings.CutPrefix(label, "order-run:"); ok && strings.TrimSpace(scopedName) != "" { - return strings.TrimSpace(scopedName) - } - } - return "" -} - func orderTrackingScope(scopedName, cityScopeRef string) (string, string) { if idx := strings.LastIndex(scopedName, ":rig:"); idx >= 0 { return "rig", scopedName[idx+5:] @@ -494,7 +469,7 @@ func orderTrackingTitle(scopedName string, orderDef orders.Order, found bool) st return scopedName } -func orderTrackingTarget(orderDef orders.Order, found bool, bead beads.Bead) string { +func orderTrackingTarget(orderDef orders.Order, found bool, run orders.OrderRun) string { if found { if orderDef.IsExec() { return "exec" @@ -506,7 +481,7 @@ func orderTrackingTarget(orderDef orders.Order, found bool, bead beads.Bead) str return orderDef.Formula } } - if orderLabelsContainExec(bead.Labels) { + if run.Outcome.IsExec() { return "exec" } return "formula" @@ -519,47 +494,19 @@ func qualifyOrderFeedTarget(pool, rig string) string { return rig + "/" + pool } -func orderTrackingType(orderDef orders.Order, found bool, bead beads.Bead) string { +func orderTrackingType(orderDef orders.Order, found bool, run orders.OrderRun) string { if found { if orderDef.IsExec() { return "exec" } return "formula" } - if orderLabelsContainExec(bead.Labels) { + if run.Outcome.IsExec() { return "exec" } return "formula" } -func orderTrackingStatus(bead beads.Bead) string { - if orderLabelsContainExecFailure(bead.Labels) || - orderLabelsContainTriggerEnvFailure(bead.Labels) || - containsString(bead.Labels, "wisp-canceled") || - containsString(bead.Labels, "wisp-failed") { - return "failed" - } - if strings.TrimSpace(bead.Status) != "closed" { - return "active" - } - return "completed" -} - -func orderLabelsContainExec(labels []string) bool { - return containsString(labels, "exec") || - containsString(labels, "exec-failed") || - containsString(labels, "exec-env-failed") -} - -func orderLabelsContainExecFailure(labels []string) bool { - return containsString(labels, "exec-failed") || - containsString(labels, "exec-env-failed") -} - -func orderLabelsContainTriggerEnvFailure(labels []string) bool { - return containsString(labels, "trigger-env-failed") -} - // normalizeFeedLimit clamps a caller-supplied feed limit to a sensible // range. 0 (or negative) means "use the default"; anything past the // hard ceiling is clipped. diff --git a/internal/api/orders_feed_test.go b/internal/api/orders_feed_test.go index 9ca00d009c..1d5ba96d1f 100644 --- a/internal/api/orders_feed_test.go +++ b/internal/api/orders_feed_test.go @@ -24,27 +24,33 @@ func TestParseOrdersFeedLimitCapsLargeValues(t *testing.T) { } func TestOrderTrackingStatusTreatsWispFailedAsFailed(t *testing.T) { - bead := beads.Bead{ + run, ok := orders.RunFromTrackingBead(beads.Bead{ Status: "closed", - Labels: []string{"order-tracking", "wisp", "wisp-failed"}, + Labels: []string{"order-tracking", "order-run:nightly", "wisp", "wisp-failed"}, + }) + if !ok { + t.Fatal("RunFromTrackingBead ok = false") } - if got := orderTrackingStatus(bead); got != "failed" { - t.Fatalf("orderTrackingStatus = %q, want failed", got) + if got := run.State(); got != "failed" { + t.Fatalf("run.State() = %q, want failed", got) } } func TestOrderTrackingExecEnvFailedClassifiesAsFailedExec(t *testing.T) { - bead := beads.Bead{ + run, ok := orders.RunFromTrackingBead(beads.Bead{ Status: "closed", Labels: []string{"order-tracking", "order-run:nightly", "exec-env-failed"}, + }) + if !ok { + t.Fatal("RunFromTrackingBead ok = false") } - if got := orderTrackingStatus(bead); got != "failed" { - t.Fatalf("orderTrackingStatus = %q, want failed", got) + if got := run.State(); got != "failed" { + t.Fatalf("run.State() = %q, want failed", got) } - if got := orderTrackingTarget(orders.Order{}, false, bead); got != "exec" { + if got := orderTrackingTarget(orders.Order{}, false, run); got != "exec" { t.Fatalf("orderTrackingTarget = %q, want exec", got) } - if got := orderTrackingType(orders.Order{}, false, bead); got != "exec" { + if got := orderTrackingType(orders.Order{}, false, run); got != "exec" { t.Fatalf("orderTrackingType = %q, want exec", got) } } @@ -61,12 +67,15 @@ func TestWorkflowProjectionTargetKeepsRunTargetMigrationFallback(t *testing.T) { func TestOrderTrackingTriggerEnvFailedClassifiesOpenAndClosedAsFailed(t *testing.T) { for _, status := range []string{"open", "closed"} { t.Run(status, func(t *testing.T) { - bead := beads.Bead{ + run, ok := orders.RunFromTrackingBead(beads.Bead{ Status: status, Labels: []string{"order-tracking", "order-run:nightly", "trigger-env-failed"}, + }) + if !ok { + t.Fatal("RunFromTrackingBead ok = false") } - if got := orderTrackingStatus(bead); got != "failed" { - t.Fatalf("orderTrackingStatus(%s) = %q, want failed", status, got) + if got := run.State(); got != "failed" { + t.Fatalf("run.State(%s) = %q, want failed", status, got) } }) } @@ -175,11 +184,12 @@ func TestBuildOrderRunFeedItemsUsesAllOrdersForDisabledExecMetadata(t *testing.T } func TestOrderTrackingUpdatedAtLogsLookupFailure(t *testing.T) { - store := labelFailListStore{ + front := orders.NewStore(beads.OrdersStore{Store: labelFailListStore{ Store: beads.NewMemStore(), failLabel: "order-run:digest", - } - tracking := beads.Bead{ + }}) + run := orders.OrderRun{ + Scoped: "digest", CreatedAt: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), } @@ -191,9 +201,9 @@ func TestOrderTrackingUpdatedAtLogsLookupFailure(t *testing.T) { } defer func() { orderFeedLogf = origLogf }() - got := orderTrackingUpdatedAt(store, tracking, "digest") - if !got.Equal(tracking.CreatedAt) { - t.Fatalf("updatedAt = %s, want %s", got, tracking.CreatedAt) + got := orderTrackingUpdatedAt(front, run) + if !got.Equal(run.CreatedAt) { + t.Fatalf("updatedAt = %s, want %s", got, run.CreatedAt) } if !strings.Contains(logs.String(), "order feed update lookup failed") { t.Fatalf("logs = %q, want update lookup failure warning", logs.String()) diff --git a/internal/convergence/gate_output.go b/internal/convergence/gate_output.go new file mode 100644 index 0000000000..4167257ffd --- /dev/null +++ b/internal/convergence/gate_output.go @@ -0,0 +1,56 @@ +package convergence + +// GateOutput is the read-side projection of the exec-gate output vocabulary — +// the convergence.gate_* metadata keys that Handler.persistGateOutcome stamps on +// convergence-loop root beads. It is the confinement boundary for that +// vocabulary: consumers (the orders API history handlers) read a GateOutput and +// never touch the convergence.gate_* keys directly, so internal/convergence +// stays the sole owner of the key literals. GateOutput is the read-side twin of +// the persistGateOutcome write path. +// +// The fields are raw strings on purpose: every consumer either forwards a value +// verbatim on the wire or does a presence check, and for these keys a +// present-but-empty value is indistinguishable from absent — so plain strings +// match the callers' `ok && v != ""` semantics exactly. +type GateOutput struct { + // DurationMs is the wall-clock gate duration in milliseconds. + DurationMs string + // ExitCode is the gate command's process exit code. + ExitCode string + // Stdout is the captured gate standard output. + Stdout string + // Stderr is the captured gate standard error. + Stderr string +} + +// GateOutputFromMetadata projects a bead's metadata onto a GateOutput, reading +// only the convergence.gate_* fields. It is nil-map safe: a nil map yields the +// zero GateOutput. +func GateOutputFromMetadata(meta map[string]string) GateOutput { + return GateOutput{ + DurationMs: meta[FieldGateDurationMs], + ExitCode: meta[FieldGateExitCode], + Stdout: meta[FieldGateStdout], + Stderr: meta[FieldGateStderr], + } +} + +// HasOutput reports whether the gate captured any stdout or stderr. +func (g GateOutput) HasOutput() bool { + return g.Stdout != "" || g.Stderr != "" +} + +// CombinedOutput returns the gate's combined output for display: stdout first, +// then stderr appended after a newline separator when both are present. It +// matches the order-history detail handler's prior inline assembly byte for +// byte. +func (g GateOutput) CombinedOutput() string { + output := g.Stdout + if g.Stderr != "" { + if output != "" { + output += "\n" + } + output += g.Stderr + } + return output +} diff --git a/internal/convergence/gate_output_test.go b/internal/convergence/gate_output_test.go new file mode 100644 index 0000000000..74c7f63e87 --- /dev/null +++ b/internal/convergence/gate_output_test.go @@ -0,0 +1,65 @@ +package convergence + +import "testing" + +// TestGateOutputFromMetadataAndCombinedOutput pins the read-side gate-output +// projection: full metadata populates every field; a nil map yields the zero +// value; HasOutput reflects stdout/stderr presence; and CombinedOutput matches +// the order-history detail handler's prior inline stdout/stderr join byte for +// byte. +func TestGateOutputFromMetadataAndCombinedOutput(t *testing.T) { + full := map[string]string{ + FieldGateDurationMs: "1200", + FieldGateExitCode: "0", + FieldGateStdout: "out", + FieldGateStderr: "err", + } + g := GateOutputFromMetadata(full) + if g.DurationMs != "1200" || g.ExitCode != "0" || g.Stdout != "out" || g.Stderr != "err" { + t.Fatalf("GateOutputFromMetadata = %+v, want all four fields populated", g) + } + if !g.HasOutput() { + t.Errorf("HasOutput = false, want true") + } + if got := g.CombinedOutput(); got != "out\nerr" { + t.Errorf("CombinedOutput = %q, want %q", got, "out\nerr") + } + + zero := GateOutputFromMetadata(nil) + if zero != (GateOutput{}) { + t.Errorf("GateOutputFromMetadata(nil) = %+v, want zero value", zero) + } + if zero.HasOutput() { + t.Errorf("HasOutput(nil) = true, want false") + } + if got := zero.CombinedOutput(); got != "" { + t.Errorf("CombinedOutput(nil) = %q, want empty", got) + } + + cases := []struct { + name string + stdout string + stderr string + wantHasOutput bool + wantCombined string + }{ + {"both", "out", "err", true, "out\nerr"}, + {"stdout only", "out", "", true, "out"}, + {"stderr only", "", "err", true, "err"}, + {"neither", "", "", false, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := GateOutputFromMetadata(map[string]string{ + FieldGateStdout: tc.stdout, + FieldGateStderr: tc.stderr, + }) + if got.HasOutput() != tc.wantHasOutput { + t.Errorf("HasOutput = %v, want %v", got.HasOutput(), tc.wantHasOutput) + } + if out := got.CombinedOutput(); out != tc.wantCombined { + t.Errorf("CombinedOutput = %q, want %q", out, tc.wantCombined) + } + }) + } +} diff --git a/internal/orders/store.go b/internal/orders/store.go index ea93dc9ce6..69bd0878f1 100644 --- a/internal/orders/store.go +++ b/internal/orders/store.go @@ -2,6 +2,7 @@ package orders import ( "fmt" + "strings" "time" "github.com/gastownhall/gascity/internal/beads" @@ -96,6 +97,37 @@ func (o RunOutcome) Labels() []string { } } +// IsExec reports whether the outcome belongs to the synchronous-exec family +// (Exec, ExecFailed, ExecEnvFailed). It is the typed replacement for the order +// feed's exec-label fallback (orderLabelsContainExec) used to derive an exec +// target/type for a run whose order definition is no longer registered. +func (o RunOutcome) IsExec() bool { + switch o { + case RunOutcomeExec, RunOutcomeExecFailed, RunOutcomeExecEnvFailed: + return true + default: + return false + } +} + +// Display returns the human-facing outcome string the check/history API reports +// for a run: "" for no outcome yet, "success" for a clean exec or wisp dispatch, +// "failed" for any failure family (exec/env/trigger failure or a failed wisp), +// and "canceled" for a canceled wisp. It is the typed replacement for the API's +// lastRunOutcomeFromLabels label crack. +func (o RunOutcome) Display() string { + switch o { + case RunOutcomeExec, RunOutcomeWisp: + return "success" + case RunOutcomeExecFailed, RunOutcomeExecEnvFailed, RunOutcomeWispFailed, RunOutcomeTriggerEnvFailed: + return "failed" + case RunOutcomeWispCanceled: + return "canceled" + default: + return "" + } +} + // EventCursor is the per-order event-bus cursor, encoded on the tracking bead // as the label pair ("order:", "seq:"). It is the high-water mark of // events the order has already consumed. @@ -120,6 +152,21 @@ type OrderRun struct { Cursor EventCursor } +// State returns the feed-facing lifecycle status of the run: "failed" when the +// terminal outcome is a failure or cancellation, "active" for an open run with +// no failure, and "completed" for a closed run with no failure. It is the exact +// truth-table replacement for the order feed's orderTrackingStatus label crack. +func (r OrderRun) State() string { + switch r.Outcome.Display() { + case "failed", "canceled": + return "failed" + } + if r.Open { + return "active" + } + return "completed" +} + // RunOpts configures CreateRun. type RunOpts struct { // Outcome, when non-None, is stamped on the created (open) bead — used by @@ -269,6 +316,75 @@ func (s *Store) RecentRuns(scoped string, limit int) ([]OrderRun, error) { return decodeRuns(scoped, beadsList), nil } +// ListTracking lists every order tracking bead across both tiers, newest-first, +// decoded into OrderRun values. It is the typed face of the /v0/orders/feed read +// it replaces: it confines the order-tracking List and the tracking-bead decode +// the feed previously performed inline. Beads with no order-run label (which +// RunFromTrackingBead rejects) are skipped. The query is byte-identical to the +// feed's prior raw scan — order-tracking label, created-desc, both tiers, and no +// IncludeClosed so only in-flight/open tracking beads surface. Decoded rows and +// any list error are returned together (the RecentRuns pattern) so callers keep +// the feed's err-branch semantics. +func (s *Store) ListTracking() ([]OrderRun, error) { + if s.store.Store == nil { + return nil, nil + } + list, err := s.store.List(beads.ListQuery{ + Label: labelOrderTracking, + Sort: beads.SortCreatedDesc, + TierMode: beads.TierBoth, + }) + runs := make([]OrderRun, 0, len(list)) + for _, b := range list { + if run, ok := RunFromTrackingBead(b); ok { + runs = append(runs, run) + } + } + return runs, err +} + +// LatestOpenRun returns the newest OPEN order-run bead for scoped, if any. The +// query deliberately omits IncludeClosed: the order feed uses the most recent +// OPEN run as the freshness signal for a tracking row's UpdatedAt, so a closed +// run must not advance it. It is byte-identical to the feed's prior raw +// order-run: lookup (limit 1, created-desc, both tiers). The decoded +// row, a found flag, and any list error are returned together; found can be true +// alongside a partial-tier error, mirroring the feed's prior handling. +func (s *Store) LatestOpenRun(scoped string) (OrderRun, bool, error) { + if s.store.Store == nil { + return OrderRun{}, false, nil + } + list, err := s.store.List(beads.ListQuery{ + Label: labelOrderRunPrefix + scoped, + Limit: 1, + Sort: beads.SortCreatedDesc, + TierMode: beads.TierBoth, + }) + if len(list) == 0 { + return OrderRun{}, false, err + } + return decodeRun(scoped, list[0]), true, err +} + +// RunFromTrackingBead projects an order tracking/run bead onto an OrderRun and +// is the exported decode entry other front-door callers (the API feed/history +// edges) use; decodeRun stays private. It is pure, side-effect-free, and +// backend-invariant (reads only bead fields), mirroring decodeRun and +// session.InfoFromPersistedBead. The scoped order name is taken from the first +// non-empty "order-run:" label (identical to the feed's former +// orderTrackingScopedName scan); a bead with no such label is not an order +// tracking record, so ok=false. +func RunFromTrackingBead(b beads.Bead) (OrderRun, bool) { + for _, label := range b.Labels { + if scoped, ok := strings.CutPrefix(label, labelOrderRunPrefix); ok { + if scoped = strings.TrimSpace(scoped); scoped != "" { + return decodeRun(scoped, b), true + } + } + } + return OrderRun{}, false +} + // decodeRun projects an order tracking/run bead onto an OrderRun. It is pure, // side-effect-free, and backend-invariant (reads only bead fields), matching the // projection-invariance invariant. The cooldown clock (CreatedAt), open flag, @@ -294,6 +410,14 @@ func decodeRuns(scoped string, list []beads.Bead) []OrderRun { // outcomeFromLabels reverses RunOutcome.Labels, reporting the terminal outcome a // tracking bead's labels encode, or RunOutcomeNone for an in-flight run. +// +// This relies on the invariant that a tracking bead is stamped with exactly ONE +// outcome family: either a single RunOutcome via SetOutcome, or the fixed +// {wisp, wisp-failed} pair from the failure path. Given that, the decode order +// (wisp family before exec/trigger) is unambiguous. A future writer that +// double-stamps mixed families (e.g. {wisp, exec-failed}) would be silently +// reclassified by this precedence; such a case must instead be modeled +// explicitly as its own RunOutcome rather than allowed to fall through here. func outcomeFromLabels(labels []string) RunOutcome { wisp := beadLabelsContain(labels, labelWisp) switch { diff --git a/internal/orders/store_test.go b/internal/orders/store_test.go index 595838e2fd..e10d815709 100644 --- a/internal/orders/store_test.go +++ b/internal/orders/store_test.go @@ -3,6 +3,7 @@ package orders import ( "reflect" "testing" + "time" "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/beads/beadstest" @@ -230,3 +231,213 @@ func TestRecentRunsReadsHistory(t *testing.T) { } } } + +// TestRunFromTrackingBeadDecodesScopedOutcomeOpenCursor proves the exported +// tracking-bead decode extracts the scoped name from the first non-empty +// order-run label and folds outcome / open / cursor exactly like decodeRun, and +// rejects beads that carry no order-run label (ok=false). +func TestRunFromTrackingBeadDecodesScopedOutcomeOpenCursor(t *testing.T) { + b := beads.Bead{ + ID: "gc-42", + Status: "open", + Labels: []string{ + "order-tracking", + "order-run:digest:rig:demo", + "wisp", + "wisp-failed", + "order:digest:rig:demo", + "seq:7", + }, + } + run, ok := RunFromTrackingBead(b) + if !ok { + t.Fatal("RunFromTrackingBead ok = false, want true") + } + want := OrderRun{ + ID: "gc-42", + Scoped: "digest:rig:demo", + Outcome: RunOutcomeWispFailed, + CreatedAt: b.CreatedAt, + Open: true, + Cursor: EventCursor(7), + } + if !reflect.DeepEqual(run, want) { + t.Fatalf("run = %+v, want %+v", run, want) + } + + if _, ok := RunFromTrackingBead(beads.Bead{Labels: []string{"order-tracking"}}); ok { + t.Errorf("RunFromTrackingBead(order-tracking only) ok = true, want false") + } + if _, ok := RunFromTrackingBead(beads.Bead{Labels: []string{"order-tracking", "order-run:"}}); ok { + t.Errorf("RunFromTrackingBead(empty order-run suffix) ok = true, want false") + } + if _, ok := RunFromTrackingBead(beads.Bead{Labels: []string{"order-run: "}}); ok { + t.Errorf("RunFromTrackingBead(whitespace order-run suffix) ok = true, want false") + } +} + +// TestRunOutcomeDisplayAndIsExec pins the display/exec vocabulary that replaces +// the API's inline label cracks. The label sub-table is ported verbatim from the +// deleted API test TestLastRunOutcomeFromLabelsPrioritizesTerminalLabels so the +// pre-refactor outcome truth table survives through outcomeFromLabels + Display. +func TestRunOutcomeDisplayAndIsExec(t *testing.T) { + cases := []struct { + outcome RunOutcome + wantDisplay string + wantIsExec bool + }{ + {RunOutcomeNone, "", false}, + {RunOutcomeExec, "success", true}, + {RunOutcomeExecFailed, "failed", true}, + {RunOutcomeExecEnvFailed, "failed", true}, + {RunOutcomeWisp, "success", false}, + {RunOutcomeWispFailed, "failed", false}, + {RunOutcomeWispCanceled, "canceled", false}, + {RunOutcomeTriggerEnvFailed, "failed", false}, + } + for _, tc := range cases { + if got := tc.outcome.Display(); got != tc.wantDisplay { + t.Errorf("Display(%v) = %q, want %q", tc.outcome, got, tc.wantDisplay) + } + if got := tc.outcome.IsExec(); got != tc.wantIsExec { + t.Errorf("IsExec(%v) = %v, want %v", tc.outcome, got, tc.wantIsExec) + } + } + + labelCases := []struct { + name string + labels []string + want string + }{ + {"wisp failed dominates success", []string{"wisp", "wisp-failed"}, "failed"}, + {"failed alone", []string{"wisp-failed"}, "failed"}, + {"exec failed dominates success", []string{"exec", "exec-failed"}, "failed"}, + {"exec env failed is failed", []string{"exec-env-failed"}, "failed"}, + {"trigger env failed is failed", []string{"trigger-env-failed"}, "failed"}, + {"canceled dominates success", []string{"wisp", "wisp-canceled"}, "canceled"}, + {"success fallback", []string{"exec"}, "success"}, + {"unknown", []string{"order-tracking"}, ""}, + } + for _, tc := range labelCases { + if got := outcomeFromLabels(tc.labels).Display(); got != tc.want { + t.Errorf("%s: outcomeFromLabels(%v).Display() = %q, want %q", tc.name, tc.labels, got, tc.want) + } + } +} + +// TestOrderRunStateMatchesLegacyFeedStatus is the equivalence tripwire for the +// deleted orderTrackingStatus: each single-outcome-family bead decodes and its +// State() must match the pre-refactor active/failed/completed classification. +func TestOrderRunStateMatchesLegacyFeedStatus(t *testing.T) { + cases := []struct { + name string + status string + labels []string + want string + }{ + {"open exec-failed", "open", []string{"order-run:s", "exec-failed"}, "failed"}, + {"open exec-env-failed", "open", []string{"order-run:s", "exec-env-failed"}, "failed"}, + {"open trigger-env-failed", "open", []string{"order-run:s", "trigger-env-failed"}, "failed"}, + {"open wisp-canceled", "open", []string{"order-run:s", "wisp", "wisp-canceled"}, "failed"}, + {"open wisp-failed", "open", []string{"order-run:s", "wisp", "wisp-failed"}, "failed"}, + {"open no-outcome", "open", []string{"order-run:s"}, "active"}, + {"closed wisp", "closed", []string{"order-run:s", "wisp"}, "completed"}, + {"closed exec", "closed", []string{"order-run:s", "exec"}, "completed"}, + {"closed no-outcome", "closed", []string{"order-run:s"}, "completed"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + run, ok := RunFromTrackingBead(beads.Bead{Status: tc.status, Labels: tc.labels}) + if !ok { + t.Fatalf("RunFromTrackingBead ok = false") + } + if got := run.State(); got != tc.want { + t.Fatalf("State() = %q, want %q", got, tc.want) + } + }) + } +} + +// TestListTrackingDecodesTrackingBeadsNewestFirst proves ListTracking mirrors +// the /v0/orders/feed order-tracking scan: newest-first, fully decoded, skipping +// beads without an order-run label, and returning (nil, nil) for a nil store. +func TestListTrackingDecodesTrackingBeadsNewestFirst(t *testing.T) { + now := time.Now() + older := beads.Bead{ + ID: "gc-1", + Status: "open", + CreatedAt: now.Add(-time.Hour), + Labels: []string{"order-tracking", "order-run:rig/a"}, + } + newer := beads.Bead{ + ID: "gc-2", + Status: "open", + CreatedAt: now, + Labels: []string{"order-tracking", "order-run:rig/b"}, + } + unlabeled := beads.Bead{ + ID: "gc-3", + Status: "open", + CreatedAt: now.Add(-30 * time.Minute), + } + mem := beads.NewMemStoreFrom(3, []beads.Bead{older, newer, unlabeled}, nil) + front := NewStore(beads.OrdersStore{Store: mem}) + + runs, err := front.ListTracking() + if err != nil { + t.Fatalf("ListTracking: %v", err) + } + if len(runs) != 2 { + t.Fatalf("runs = %d, want 2", len(runs)) + } + if runs[0].Scoped != "rig/b" || runs[1].Scoped != "rig/a" { + t.Fatalf("order = [%s %s], want [rig/b rig/a] (newest first)", runs[0].Scoped, runs[1].Scoped) + } + + got, err := NewStore(beads.OrdersStore{}).ListTracking() + if err != nil || got != nil { + t.Fatalf("nil-store ListTracking = (%v, %v), want (nil, nil)", got, err) + } +} + +// TestLatestOpenRunIgnoresClosedRuns pins the deliberate IncludeClosed omission +// (adjustment B): the freshness signal is the newest OPEN run, so a newer closed +// run must not win, and an all-closed scoped name reports found=false. +func TestLatestOpenRunIgnoresClosedRuns(t *testing.T) { + now := time.Now() + olderOpen := beads.Bead{ + ID: "gc-1", + Status: "open", + CreatedAt: now.Add(-time.Hour), + Labels: []string{"order-tracking", "order-run:rig/agent"}, + } + newerClosed := beads.Bead{ + ID: "gc-2", + Status: "closed", + CreatedAt: now, + Labels: []string{"order-tracking", "order-run:rig/agent", "wisp"}, + } + mem := beads.NewMemStoreFrom(2, []beads.Bead{olderOpen, newerClosed}, nil) + front := NewStore(beads.OrdersStore{Store: mem}) + + run, found, err := front.LatestOpenRun("rig/agent") + if err != nil { + t.Fatalf("LatestOpenRun: %v", err) + } + if !found { + t.Fatal("found = false, want true (older open run)") + } + if run.ID != "gc-1" { + t.Errorf("run.ID = %q, want gc-1 (open run, not the newer closed run)", run.ID) + } + + allClosed := beads.NewMemStoreFrom(1, []beads.Bead{{ + ID: "gc-9", + Status: "closed", + CreatedAt: now, + Labels: []string{"order-tracking", "order-run:rig/agent"}, + }}, nil) + if _, found, err := NewStore(beads.OrdersStore{Store: allClosed}).LatestOpenRun("rig/agent"); err != nil || found { + t.Fatalf("all-closed LatestOpenRun = (found=%v, err=%v), want (false, nil)", found, err) + } +} From 730a0b920213cfe7a0f07c14a3fb484ae85220b8 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 8 Jul 2026 21:29:19 -0700 Subject: [PATCH 024/225] simplify(S23) phase 1: tick-scoped Info fold front-door + source-scan guard (#4045) Phase 1 of the Info-migration: routes all Info writes through one tick-scoped mutator + a source-scan guard that makes the forgotten-fold coherence bug class unrepresentable; raw-bead mirror retained so behavior is unchanged. Phases 2 (mirror removal) & 3 (god-file split) are folded into the S19 reconciler roadmap. #3789/#1029/#3872. Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/gc/reconcile_tick.go | 83 ++++++++++++++++++++ cmd/gc/reconcile_tick_test.go | 139 ++++++++++++++++++++++++++++++++++ cmd/gc/session_reconciler.go | 117 ++++++++++++++-------------- 3 files changed, 281 insertions(+), 58 deletions(-) create mode 100644 cmd/gc/reconcile_tick.go create mode 100644 cmd/gc/reconcile_tick_test.go diff --git a/cmd/gc/reconcile_tick.go b/cmd/gc/reconcile_tick.go new file mode 100644 index 0000000000..2391c172c3 --- /dev/null +++ b/cmd/gc/reconcile_tick.go @@ -0,0 +1,83 @@ +package main + +import ( + "github.com/gastownhall/gascity/internal/beads" + sessionpkg "github.com/gastownhall/gascity/internal/session" +) + +// reconcileTick owns the reconciler's coherent typed snapshot (infoByID) for a +// single tick and is the ONE front door for folding a mutation onto it. +// +// Every forward-pass metadata write in the reconciler produces a patch that is +// mirrored to three representations kept coherent by hand: the store (via +// sessFront.ApplyPatch), the raw beads.Bead (session.Metadata[k]=v), and this +// typed snapshot. The store write and raw-bead mirror stay where the write +// helpers perform them (healStateWithRollback, checkRateLimitStability, +// rollbackPendingCreate, …) — this type does not duplicate them. What it owns +// is the third write: the infoByID fold. Historically that fold was an open- +// coded `infoByID[id] = infoByID[id].ApplyPatch(patch)` repeated at ~30 sites, +// and a forgotten fold was a silent, compile-clean coherence bug in the +// cross-session min-floor / awake / drain scans that read the snapshot. Routing +// every fold through apply/applyResult/markClosed makes that bug class +// unrepresentable: there is one fold path, guarded by TestReconcileTickFold +// FrontDoor (which forbids a bare `infoByID[...] =` outside this file) and by +// the property test TestReconcileTickApplyMatchesRawFold. +// +// The struct holds the same map instances the reconciler reads from, so callers +// may keep reading through a plain `infoByID` alias and passing it to scan +// helpers; only the write path is funneled here. +type reconcileTick struct { + // infoByID is the coherent typed snapshot of the tick's working set, keyed + // by session ID. Built once from the post-Phase-0.5 `ordered` beads. + infoByID map[string]sessionpkg.Info + // orderedIDs carries the tick's topo order as plain session IDs. Order is + // load-bearing: ComputeAwakeSet resolves the non-unique SessionName + // last-write-wins, so order-sensitive rebuilds walk this instead of ranging + // the (unordered) map. + orderedIDs []string +} + +// newReconcileTick builds the tick snapshot from the tick's ordered working +// set. Each entry is byte-identical to a fresh projection of that session's +// bead at loop entry; the forward pass mutates only the current iteration's +// session, so no entry goes stale before it is visited. +func newReconcileTick(ordered []beads.Bead) *reconcileTick { + t := &reconcileTick{ + infoByID: make(map[string]sessionpkg.Info, len(ordered)), + orderedIDs: make([]string, len(ordered)), + } + for i := range ordered { + t.orderedIDs[i] = ordered[i].ID + t.infoByID[ordered[i].ID] = sessionpkg.InfoFromPersistedBead(ordered[i]) + } + return t +} + +// apply folds a metadata patch onto the snapshot entry for id and returns the +// updated Info. Equivalent to the former `infoByID[id] = infoByID[id].ApplyPatch +// (patch)`; the store write and raw-bead mirror are performed by the caller's +// write helper before this fold. +func (t *reconcileTick) apply(id string, patch sessionpkg.MetadataPatch) sessionpkg.Info { + next := t.infoByID[id].ApplyPatch(patch) + t.infoByID[id] = next + return next +} + +// applyResult folds a drainAckFinalizeResult onto the snapshot entry for id and +// returns the updated Info. Equivalent to the former +// `infoByID[id] = result.applyTo(infoByID[id])`. +func (t *reconcileTick) applyResult(id string, r drainAckFinalizeResult) sessionpkg.Info { + next := r.applyTo(t.infoByID[id]) + t.infoByID[id] = next + return next +} + +// markClosed records an in-memory close on the snapshot entry for id (Closed +// =true, State=""). Equivalent to the former +// `infoByID[id] = infoByID[id].MarkClosed()`; the store close was already +// stamped by the caller's close helper. +func (t *reconcileTick) markClosed(id string) sessionpkg.Info { + next := t.infoByID[id].MarkClosed() + t.infoByID[id] = next + return next +} diff --git a/cmd/gc/reconcile_tick_test.go b/cmd/gc/reconcile_tick_test.go new file mode 100644 index 0000000000..0638a3c18d --- /dev/null +++ b/cmd/gc/reconcile_tick_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "regexp" + "runtime" + "strings" + "testing" + + "github.com/gastownhall/gascity/internal/beads" + sessionpkg "github.com/gastownhall/gascity/internal/session" +) + +func tickTestBead(id, name, state string) beads.Bead { + return beads.Bead{ + ID: id, + Status: "open", + Type: "session", + Metadata: beads.StringMap{ + "session_name": name, + "state": state, + }, + } +} + +// TestNewReconcileTickMatchesProjection pins that the tick snapshot is built +// byte-identically to a fresh projection of each bead, in topo order. +func TestNewReconcileTickMatchesProjection(t *testing.T) { + ordered := []beads.Bead{ + tickTestBead("s-1", "alpha", "awake"), + tickTestBead("s-2", "beta", "asleep"), + tickTestBead("s-3", "gamma", "creating"), + } + tick := newReconcileTick(ordered) + + if len(tick.orderedIDs) != len(ordered) { + t.Fatalf("orderedIDs len = %d, want %d", len(tick.orderedIDs), len(ordered)) + } + for i := range ordered { + if tick.orderedIDs[i] != ordered[i].ID { + t.Errorf("orderedIDs[%d] = %q, want %q", i, tick.orderedIDs[i], ordered[i].ID) + } + want := sessionpkg.InfoFromPersistedBead(ordered[i]) + if got := tick.infoByID[ordered[i].ID]; !reflect.DeepEqual(got, want) { + t.Errorf("infoByID[%q] = %+v, want %+v", ordered[i].ID, got, want) + } + } +} + +// TestReconcileTickApplyMatchesRawFold is the property test for the mutator: +// tick.apply / tick.markClosed must fold the snapshot identically to applying +// the same operation directly on a fresh projection, and the stored entry must +// equal the returned Info. This is the coherence guarantee that the front door +// enforces at every fold site (store == raw == snapshot, with store/raw +// performed by the caller's write helper). +func TestReconcileTickApplyMatchesRawFold(t *testing.T) { + base := tickTestBead("s-1", "alpha", "creating") + patches := []sessionpkg.MetadataPatch{ + {"state": "awake"}, + {"state": "asleep", "sleep_reason": "drained"}, + {"pending_create_claim": "", "pending_create_started_at": ""}, + {"session_name": "renamed"}, + } + + for _, patch := range patches { + tick := newReconcileTick([]beads.Bead{base}) + want := sessionpkg.InfoFromPersistedBead(base).ApplyPatch(patch) + got := tick.apply(base.ID, patch) + if !reflect.DeepEqual(got, want) { + t.Errorf("apply(%v) returned %+v, want %+v", map[string]string(patch), got, want) + } + if stored := tick.infoByID[base.ID]; !reflect.DeepEqual(stored, want) { + t.Errorf("apply(%v) stored %+v, want %+v", map[string]string(patch), stored, want) + } + } + + // markClosed folds identically to a direct MarkClosed on the projection. + tick := newReconcileTick([]beads.Bead{base}) + wantClosed := sessionpkg.InfoFromPersistedBead(base).MarkClosed() + gotClosed := tick.markClosed(base.ID) + if !reflect.DeepEqual(gotClosed, wantClosed) { + t.Errorf("markClosed returned %+v, want %+v", gotClosed, wantClosed) + } + if stored := tick.infoByID[base.ID]; !reflect.DeepEqual(stored, wantClosed) { + t.Errorf("markClosed stored %+v, want %+v", stored, wantClosed) + } +} + +// TestReconcileTickApplyResultMatchesApplyTo pins that applyResult folds a +// drainAckFinalizeResult identically to calling result.applyTo on the snapshot +// entry. +func TestReconcileTickApplyResultMatchesApplyTo(t *testing.T) { + base := tickTestBead("s-1", "alpha", "awake") + res := drainAckFinalizeResult{batch: map[string]string{"state": "asleep"}, closed: true} + + tick := newReconcileTick([]beads.Bead{base}) + want := res.applyTo(sessionpkg.InfoFromPersistedBead(base)) + got := tick.applyResult(base.ID, res) + if !reflect.DeepEqual(got, want) { + t.Errorf("applyResult returned %+v, want %+v", got, want) + } + if stored := tick.infoByID[base.ID]; !reflect.DeepEqual(stored, want) { + t.Errorf("applyResult stored %+v, want %+v", stored, want) + } +} + +// infoByIDBareAssign matches a direct assignment into a bare infoByID map — +// `infoByID[] = ` (but not `==`) — the open-coded fold the mutator +// replaces. +var infoByIDBareAssign = regexp.MustCompile(`\binfoByID\[[^\]]*\]\s*=[^=]`) + +// TestReconcileTickFoldFrontDoor forbids reintroducing a direct +// `infoByID[...] =` fold in session_reconciler.go: every mutation of the tick +// snapshot must route through the reconcileTick front door (apply / applyResult +// / markClosed) so a forgotten fold cannot silently desync the cross-session +// min-floor / awake / drain scans from the store and raw bead. The only place a +// bare `t.infoByID[...] =` write is allowed is reconcile_tick.go itself. +func TestReconcileTickFoldFrontDoor(t *testing.T) { + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + path := filepath.Join(filepath.Dir(currentFile), "session_reconciler.go") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q): %v", path, err) + } + for i, line := range strings.Split(string(data), "\n") { + code := line + if idx := strings.Index(code, "//"); idx >= 0 { + code = code[:idx] // strip line/inline comment + } + if infoByIDBareAssign.MatchString(code) { + t.Errorf("session_reconciler.go:%d writes infoByID directly (%q); route the fold through the reconcileTick front door (tick.apply / tick.applyResult / tick.markClosed) instead", i+1, strings.TrimSpace(line)) + } + } +} diff --git a/cmd/gc/session_reconciler.go b/cmd/gc/session_reconciler.go index 4bffc8a687..5cb1adc871 100644 --- a/cmd/gc/session_reconciler.go +++ b/cmd/gc/session_reconciler.go @@ -1477,20 +1477,21 @@ func reconcileSessionBeadsTracedWithNamedDemand( // — Phase 1 mutates only the current iteration's session, so no entry goes // stale before it is visited. Entries are refreshed from the store (via Get) // after a mutation as the post-mutation reads migrate onto them (Step 3+). - infoByID := make(map[string]sessionpkg.Info, len(ordered)) - // orderedIDs carries the tick's topo order as plain session IDs (Step 5e). The - // order-sensitive decision-domain rebuilds (the awake-scan `sessionInfos` feed - // and the preserve-template feed) walk it instead of the raw `ordered` beads, - // so those rebuilds no longer reach into `ordered[i]` — `ordered` is demoted to - // the load-time slice that builds this snapshot and carries raw beads into the - // documented raw-by-design / start-execution consumers. Order is load-bearing: - // ComputeAwakeSet resolves the non-unique SessionName last-write-wins, so these - // rebuilds must stay in topo order and never `range infoByID`. - orderedIDs := make([]string, len(ordered)) - for i := range ordered { - orderedIDs[i] = ordered[i].ID - infoByID[ordered[i].ID] = sessionpkg.InfoFromPersistedBead(ordered[i]) - } + // tick owns the coherent typed snapshot for this tick and is the single + // front door for folding a mutation onto it (see reconcileTick). Every + // forward-pass write below routes its infoByID fold through tick.apply / + // tick.applyResult / tick.markClosed; a bare `infoByID[...] =` here is + // forbidden by TestReconcileTickFoldFrontDoor. Reads still go through the + // plain `infoByID` alias (same map instance) and scan helpers still take it + // by value. orderedIDs carries the tick's topo order as plain session IDs; + // the order-sensitive rebuilds (the awake-scan `sessionInfos` feed and the + // preserve-template feed) walk it instead of the raw `ordered` beads. Order + // is load-bearing: ComputeAwakeSet resolves the non-unique SessionName + // last-write-wins, so these rebuilds must stay in topo order and never + // `range infoByID`. + tick := newReconcileTick(ordered) + infoByID := tick.infoByID + orderedIDs := tick.orderedIDs // Phase 1: Forward pass (topo order) — wake sessions, handle alive state. var startCandidates []startCandidate var wakeTargets []wakeTarget @@ -1572,7 +1573,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // unmutated bead. infoByID[session.ID] is coherent here (top-of-loop // snapshot, no *session mutation before the finalize call). Guarded by // TestReconcileSessionBeads_MinFloorCountReflectsMidTickCloseDrainAck. - infoByID[session.ID] = result.applyTo(infoByID[session.ID]) + tick.applyResult(session.ID, result) continue } @@ -1640,14 +1641,14 @@ func reconcileSessionBeadsTracedWithNamedDemand( if rateLimitHit || rateLimitErr != nil { // Fold the rate-limit batch onto the snapshot (Step 6d write-returns-Info). // Pre-pass-masked (STEP6-PREPASS-AUDIT group 1). - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(rlBatch) + tick.apply(session.ID, rlBatch) continue } clearClaim := configuredNamedSessionBeadHasSpecInfo(info, cfg, cityName) // Fold the rollback's mirrored metadata onto the snapshot (Step 6d // write-returns-Info; no Closed change — store-only close). // Pre-pass-masked (STEP6-PREPASS-AUDIT group 2). - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(attemptRollbackPendingCreate(session, template, name, "pending_create_lease_expired", "lease expired and no live runtime", clearClaim)) + tick.apply(session.ID, attemptRollbackPendingCreate(session, template, name, "pending_create_lease_expired", "lease expired and no live runtime", clearClaim)) continue } } @@ -1712,7 +1713,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( } // Fold the rate-limit batch onto the snapshot (Step 6d write-returns-Info). // Pre-pass-masked (STEP6-PREPASS-AUDIT group 1). - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(rlBatchNamed) + tick.apply(session.ID, rlBatchNamed) continue } if isFailedCreateSessionInfo(info) { @@ -1765,7 +1766,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // half of the Step-6d front-door cutover; the raw session.Status // lockstep above stays until the final lockstep drop. Guarded by // TestReconcileSessionBeads_MinFloorCountReflectsMidTickClose. - infoByID[session.ID] = infoByID[session.ID].MarkClosed() + tick.markClosed(session.ID) } continue } @@ -1815,7 +1816,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // post-zombie rollback read on the preserveNamed fall-through) through this // fold alone. Guarded by // TestReconcileSessionBeads_HealStateReflectedOnSnapshot. - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(healBatch) + tick.apply(session.ID, healBatch) infoPostHeal := infoByID[session.ID] switch { case preserveNamed: @@ -1912,7 +1913,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // time-independent, so reconstructing the patch reproduces the // mirror (drain_at is non-Info). Cross-session isDrainAckStopPendingInfo // reader. Pre-pass-masked (STEP6-PREPASS-AUDIT group 3). - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(sessionpkg.DrainAckStopPendingPatch(clk.Now().UTC())) + tick.apply(session.ID, sessionpkg.DrainAckStopPendingPatch(clk.Now().UTC())) clearDrainTrackerForStopPending(session, dt) queueDrainAckAsyncStop(cityPath, store, sp, cfg, session.ID, name, session.Metadata["instance_token"], asyncStopTracker, stderr) if trace != nil { @@ -1950,7 +1951,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // refreshSessionInfo re-projection). infoByID[session.ID] holds the // coherent post-heal Info (refreshed at the heal above; no *session // mutation reaches here on this !providerAlive path). - infoByID[session.ID] = result.applyTo(infoByID[session.ID]) + tick.applyResult(session.ID, result) continue } } @@ -2077,7 +2078,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // The heal refresh (~1628) already synced this entry, so // MarkClosed folds onto a coherent pre-close Info. Guarded by // TestReconcileSessionBeads_MinFloorCountReflectsMidTickCloseOrphan. - infoByID[session.ID] = infoByID[session.ID].MarkClosed() + tick.markClosed(session.ID) } } continue @@ -2144,12 +2145,12 @@ func reconcileSessionBeadsTracedWithNamedDemand( // paths (attemptRollbackPendingCreate; checkRateLimitStability on hit), so // infoPostZombie stays byte-identical throughout. Guarded by // TestReconcileSessionBeads_ZombieTerminalErrorReflectedOnSnapshot. - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(terminalErrBatch) + tick.apply(session.ID, terminalErrBatch) infoPostZombie := infoByID[session.ID] if alive && shouldRollbackPendingCreateInfo(infoPostZombie) && !runningSessionMatchesPendingCreate(session, name, sp) { // Fold the rollback's mirrored metadata onto the snapshot (Step 6d; // no Closed change — store-only close). STEP6-PREPASS-AUDIT group 2. - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(attemptRollbackPendingCreate(session, tp.TemplateName, name, "pending_create_rollback", "live runtime belongs to another session", false)) + tick.apply(session.ID, attemptRollbackPendingCreate(session, tp.TemplateName, name, "pending_create_rollback", "live runtime belongs to another session", false)) continue } // Desired-branch counterpart to pendingCreateSessionStillLeased: a @@ -2169,12 +2170,12 @@ func reconcileSessionBeadsTracedWithNamedDemand( if rateLimitHit || rateLimitErr != nil { // Fold the rate-limit batch onto the snapshot (Step 6d write-returns-Info). // Pre-pass-masked (STEP6-PREPASS-AUDIT group 1). - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(rlBatch) + tick.apply(session.ID, rlBatch) continue } // Fold the rollback's mirrored metadata onto the snapshot (Step 6d; // no Closed change — store-only close). STEP6-PREPASS-AUDIT group 2. - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(attemptRollbackPendingCreate(session, tp.TemplateName, name, "pending_create_lease_expired", "lease expired and no live runtime", false)) + tick.apply(session.ID, attemptRollbackPendingCreate(session, tp.TemplateName, name, "pending_create_lease_expired", "lease expired and no live runtime", false)) continue } } @@ -2293,7 +2294,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // Fold the stop-pending transition onto the snapshot (Step 6d); // deterministic DrainAckStopPendingPatch reconstruction, same as the // orphan-arm site above (STEP6-PREPASS-AUDIT group 3). - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(sessionpkg.DrainAckStopPendingPatch(clk.Now().UTC())) + tick.apply(session.ID, sessionpkg.DrainAckStopPendingPatch(clk.Now().UTC())) clearDrainTrackerForStopPending(session, dt) queueDrainAckAsyncStop(cityPath, store, sp, cfg, session.ID, name, session.Metadata["instance_token"], asyncStopTracker, stderr) if trace != nil { @@ -2318,7 +2319,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // refreshSessionInfo re-projection). infoByID[session.ID] holds the // coherent post-zombie Info (refreshed above; no *session mutation // reaches here on this !alive fall-through path). - infoByID[session.ID] = result.applyTo(infoByID[session.ID]) + tick.applyResult(session.ID, result) continue } } @@ -2408,7 +2409,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // clears it on the snapshot (else #2574 re-fires a phantom second // restart). The base is coherent here (the zombie fold synced // infoByID and every intervening mutating block `continue`s). - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(sessionpkg.MetadataPatch{"restart_requested": "true"}) + tick.apply(session.ID, sessionpkg.MetadataPatch{"restart_requested": "true"}) fmt.Fprintf(stderr, "session reconciler: %s progress-stalled (no progress for >%s, no open claim, provider healthy); requesting fresh restart\n", name, threshold) //nolint:errcheck } } @@ -2487,7 +2488,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( session.Metadata[key] = value restartFold[key] = value } - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(restartFold) + tick.apply(session.ID, restartFold) if runtimeRunning { if tmuxRequested && dops != nil { if err := dops.clearRestartRequested(name); err != nil { @@ -2515,7 +2516,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( if rateLimitHit || rateLimitErr != nil { // Fold the rate-limit batch onto the snapshot (Step 6d write-returns-Info). // Pre-pass-masked (STEP6-PREPASS-AUDIT group 1). - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(rlBatchFwd) + tick.apply(session.ID, rlBatchFwd) continue // rate-limit hold recorded before state healing resets continuity metadata } @@ -2543,7 +2544,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // restart/drain-ack blocks above either `continue` or self-refresh. This is // one of the forward-pass writers the blanket pre-pass still masks; folding it // is a prerequisite for that pre-pass's deletion (STEP6-PREPASS-AUDIT group 4). - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(healBatch) + tick.apply(session.ID, healBatch) if recoverPendingIdleSleep(session, sessFront, running, clk) { alive = false // Fold the idle-stop-pending recovery sleep onto the snapshot (Step 6d). @@ -2552,12 +2553,12 @@ func reconcileSessionBeadsTracedWithNamedDemand( // the same SleepPatch reproduces the mirror exactly (slept_at / // sleep_policy_fingerprint are non-Info). Pre-pass-masked (STEP6-PREPASS-AUDIT // group 6). - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(sessionpkg.SleepPatch(clk.Now().UTC(), string(sessionpkg.SleepReasonIdle))) + tick.apply(session.ID, sessionpkg.SleepPatch(clk.Now().UTC(), string(sessionpkg.SleepReasonIdle))) } // Fold detached_at change onto the snapshot (Step 6d write-returns-Info). // reconcileDetachedAt returns the {"detached_at": } batch it mirrored, // or nil on no-op. Pre-pass-masked (STEP6-PREPASS-AUDIT group 6). - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(reconcileDetachedAt(session, store, policy, alive, sp, clk)) + tick.apply(session.ID, reconcileDetachedAt(session, store, policy, alive, sp, clk)) // Stability check: detect rapid crash after state healing. Rate-limit // detection intentionally ran above before healState. @@ -2565,7 +2566,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // nil (no-op) when no stability event was recorded. // Pre-pass-masked (STEP6-PREPASS-AUDIT group 2). if stab, stabBatch := checkStability(session, cfg, alive, dt, sessFront, clk, nil); stab { - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(stabBatch) + tick.apply(session.ID, stabBatch) continue // rapid exit recorded, skip further processing } @@ -2577,7 +2578,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // regardless of the bool — ExitProductiveDeath may clear churn_count. // Pre-pass-masked (STEP6-PREPASS-AUDIT group 5). churn, churnBatch := checkChurn(session, cfg, alive, dt, sessFront, clk) - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(churnBatch) + tick.apply(session.ID, churnBatch) if churn { continue // churn recorded, skip further processing } @@ -2586,13 +2587,13 @@ func reconcileSessionBeadsTracedWithNamedDemand( // Fold the returned batch onto the snapshot (Step 6d write-returns-Info); // nil (no-op) when nothing was cleared. Pre-pass-masked (STEP6-PREPASS-AUDIT group 5). if alive && stableLongEnough(*session, clk) { - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(clearWakeFailures(session, sessFront)) + tick.apply(session.ID, clearWakeFailures(session, sessFront)) } // Clear churn counter for sessions that have been productive. // Fold the returned batch onto the snapshot (Step 6d write-returns-Info); // nil (no-op) when churn_count was already absent/zero. Pre-pass-masked (STEP6-PREPASS-AUDIT group 5). if alive && productiveLongEnough(*session, clk) { - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(clearChurn(session, sessFront)) + tick.apply(session.ID, clearChurn(session, sessFront)) } if alive && shouldRollbackPendingCreate(session) { switch stateBeforeHeal { @@ -2619,7 +2620,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( if !ok { fmt.Fprintf(stderr, "session reconciler: recovering pending create %s: metadata repair incomplete\n", name) //nolint:errcheck } - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(commitBatch) + tick.apply(session.ID, commitBatch) } // driftRestartedInPlace tracks whether the alive-restart branch ran @@ -2663,7 +2664,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( } else { fmt.Fprintf(stderr, "rebaselined legacy hash for %s (stored=%s current=%s)\n", name, truncateHashForLog(storedHash), truncateHashForLog(currentHash)) //nolint:errcheck } - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(rebaseBatch) + tick.apply(session.ID, rebaseBatch) if trace != nil { trace.RecordDecision(TraceSiteReconcilerConfigDrift, TraceReasonConfigDrift, outcome, tp.TemplateName, name, traceRecordPayload{ "stored_hash": storedHash, @@ -2754,7 +2755,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // session_key and continuation_reset_pending stay intentionally // unthreaded (no same-tick Info reader) and self-heal on the next // store reload. ApplyPatch(nil) is a no-op. - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(launchBatch) + tick.apply(session.ID, launchBatch) if relaunched { continue } @@ -2763,7 +2764,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // write-returns-Info). The alive lane falls through to the // aggregating refresh @~2710 today, but folding here future-proofs // that refresh's retirement (STEP6-PREPASS-AUDIT group 10). - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(resetConfiguredNamedSessionForConfigDrift(session, store, sp, name, alive, string(sessionpkg.StateStartPending), clk.Now().UTC(), stderr)) + tick.apply(session.ID, resetConfiguredNamedSessionForConfigDrift(session, store, sp, name, alive, string(sessionpkg.StateStartPending), clk.Now().UTC(), stderr)) if trace != nil { trace.RecordDecision(TraceSiteReconcilerConfigDrift, TraceReasonConfigDrift, TraceOutcomeRestartInPlace, tp.TemplateName, name, configDriftTracePayload(storedHash, currentHash, driftedFields, nil)) } @@ -2831,7 +2832,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // session_key and continuation_reset_pending stay intentionally // unthreaded (no same-tick Info reader) and self-heal on the next // store reload. ApplyPatch(nil) is a no-op. - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(launchBatch) + tick.apply(session.ID, launchBatch) if relaunched { continue } @@ -2889,7 +2890,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( } else { fmt.Fprintf(stderr, "rebaselined legacy live hash for %s (stored=%s current=%s)\n", name, truncateHashForLog(storedLive), truncateHashForLog(currentLive)) //nolint:errcheck } - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(rebaseBatch) + tick.apply(session.ID, rebaseBatch) if trace != nil { trace.RecordDecision(TraceSiteReconcilerLiveDrift, TraceReasonLiveDrift, outcome, tp.TemplateName, name, traceRecordPayload{ "stored_hash": storedLive, @@ -2959,7 +2960,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( } else { fmt.Fprintf(stderr, "rebaselined legacy hash for %s (stored=%s current=%s)\n", name, truncateHashForLog(storedHash), truncateHashForLog(currentHash)) //nolint:errcheck } - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(rebaseBatch) + tick.apply(session.ID, rebaseBatch) if trace != nil { trace.RecordDecision(TraceSiteReconcilerConfigDrift, TraceReasonConfigDrift, outcome, tp.TemplateName, name, traceRecordPayload{ "stored_hash": storedHash, @@ -2973,7 +2974,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // write-returns-Info); this asleep lane `continue`s, so the fold must // run before the continue. Clears restart_requested on the snapshot // (#2574). Pre-pass-masked (STEP6-PREPASS-AUDIT group 10). - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(resetConfiguredNamedSessionForConfigDrift(session, store, sp, name, false, "asleep", clk.Now().UTC(), stderr)) + tick.apply(session.ID, resetConfiguredNamedSessionForConfigDrift(session, store, sp, name, false, "asleep", clk.Now().UTC(), stderr)) if trace != nil { trace.RecordDecision(TraceSiteReconcilerConfigDrift, TraceReasonConfigDrift, TraceOutcomeRepairInPlace, tp.TemplateName, name, configDriftTracePayload(storedHash, currentHash, driftedFields, nil)) } @@ -3070,7 +3071,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // startCandidates, and the start executor reads last_woke_at (cleared // by SleepPatch) off the raw bead via wakeFairnessTime before it // re-Gets from the store; dropping the mirror would perturb ordering. - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(batch) + tick.apply(session.ID, batch) alive = false } } @@ -3157,7 +3158,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // loop above is RETAINED — same rationale as the max-age kill: the // same-tick re-wake reads last_woke_at (cleared by SleepPatch) off the // raw bead via wakeFairnessTime before the start executor re-Gets it. - infoByID[session.ID] = infoByID[session.ID].ApplyPatch(batch) + tick.apply(session.ID, batch) alive = false } } @@ -3288,7 +3289,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // inheriting the first episode's stale timestamp. See clearStrandedEventMarker. if target.alive { if fold := clearStrandedEventMarker(target.session, sessFront, stderr); fold != nil { - infoByID[target.session.ID] = infoByID[target.session.ID].ApplyPatch(fold) + tick.apply(target.session.ID, fold) } } @@ -3364,7 +3365,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( }) } if fold := recordCurrentBeadIDOnWake(target.session, sessFront, decision.AssignedWorkBeadID, stderr); fold != nil { - infoByID[target.session.ID] = infoByID[target.session.ID].ApplyPatch(fold) + tick.apply(target.session.ID, fold) } startCandidates = append(startCandidates, startCandidate{ session: target.session, @@ -3385,7 +3386,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( if decision.RequiresFreshCycle && info.WakeMode == "fresh" { if ran, fold := cycleAliveSessionForFreshReassign(target.session, target.tp, sp, store, cfg, cb, name, decision.AssignedWorkBeadID, clk.Now(), stdout, stderr, trace); ran { if fold != nil { - infoByID[target.session.ID] = infoByID[target.session.ID].ApplyPatch(fold) + tick.apply(target.session.ID, fold) } continue } @@ -3395,7 +3396,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // already alive before this metadata existed and refreshes the // record after the agent picks up its next bead in resume mode. if fold := recordCurrentBeadIDOnWake(target.session, sessFront, decision.AssignedWorkBeadID, stderr); fold != nil { - infoByID[target.session.ID] = infoByID[target.session.ID].ApplyPatch(fold) + tick.apply(target.session.ID, fold) } // Session is correctly awake. Cancel any non-drift drain // (handles scale-back-up: agent returns to desired set while draining). @@ -3408,7 +3409,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // session bead anywhere downstream this tick — so Step 5c dropped the // raw session.Metadata mirror. _ = sessionFrontDoor(store).SetMarker(target.session.ID, "sleep_intent", "") - infoByID[target.session.ID] = infoByID[target.session.ID].ApplyPatch(sessionpkg.MetadataPatch{"sleep_intent": ""}) + tick.apply(target.session.ID, sessionpkg.MetadataPatch{"sleep_intent": ""}) } } @@ -3437,7 +3438,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( } if intent != "idle-stop-pending" { if fold := markIdleSleepPending(target.session, sessFront); fold != nil { - infoByID[target.session.ID] = infoByID[target.session.ID].ApplyPatch(fold) + tick.apply(target.session.ID, fold) } } } @@ -3486,7 +3487,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // generation; the throttle marker on the bead itself // keeps subsequent reconciler ticks quiet. if fold := emitSessionStrandedDiagnostic(cityPath, cfg, store, rigStores, target.session, target.tp.TemplateName, rec, clk, stderr); fold != nil { - infoByID[target.session.ID] = infoByID[target.session.ID].ApplyPatch(fold) + tick.apply(target.session.ID, fold) } // Beyond diagnosis: once THIS stranding episode has been confirmed // across the confirmation window (stranded_event_emitted_at aged past @@ -3504,7 +3505,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // named-session retirement uses. if !storeQueryPartial && repairStrandedPoolWorkerBead(store, rigStores, target.session, retiredSessionFallbackRoute(*target.session), clk, stderr) { - infoByID[target.session.ID] = infoByID[target.session.ID].MarkClosed() + tick.markClosed(target.session.ID) pruneAgentHomeWorktreeIfSafe(*target.session, cityPath, cfg, stderr) } } @@ -3526,7 +3527,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( if closeBead(store, target.session.ID, closeReason, clk.Now().UTC(), stderr) { // Store-only close family: mirror the close onto the snapshot // (write-returns-Info) so a later reader sees Closed=true. - infoByID[target.session.ID] = infoByID[target.session.ID].MarkClosed() + tick.markClosed(target.session.ID) // Pool worktrees are transient by design — reclaim disk // when the session bead is retired. Skipped under safety // gates (uncommitted, unpushed, stashed) and overridable From 2d28f00a8789541939e0a704ec4d270169716afa Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 8 Jul 2026 22:08:58 -0700 Subject: [PATCH 025/225] refactor(api): route workflow-bead snapshot loops through a molecule codec (#4049) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Mints a single `molecule.WorkflowBeadFromBead` codec and routes the **three byte-identical `workflowBeadResponse` build loops** in `internal/api` through one mapper, eliminating triplicated raw-bead metadata cracks: - `handler_convoy_dispatch.go` — `snapshotFromStore` - `convoy_sql.go` — `tryFullWorkflowSQL` - `convoy_event_stream.go` — `projectWorkflowEvent` `WorkflowBeadFromBead` (+ standalone `WorkflowStatus`/`WorkflowKind`/`WorkflowAttempt`) are verbatim moves of the former inline `internal/api` helpers, so status (`Status` + `gc.outcome`), kind, and attempt derivation are unchanged. ## No wire change `BeadGraphResponse` continues to ship `beads.Bead` as before — this PR is the internal codec + loop-consolidation only. `TestOpenAPISpecInSync` and `make dashboard-check` both pass; no `openapi.json`, generated TS, or dashboard file is touched. (Returning a typed view on the wire is a deliberate, separate follow-up.) ## Scope discipline `WorkflowBead` carries **only** the 10 fields the mapper consumes. It does not duplicate `api.resolvedWorkflowID` (which stays the single implementation at its 6 call sites) or project speculative unused fields — no premature abstraction, one source of truth. ## Verification - `go build ./...`, `go vet ./internal/molecule ./internal/api` clean; `gofmt` clean - `go test ./internal/molecule ./internal/api` green (incl. `TestOpenAPISpecInSync`) - `make dashboard-check` green - TDD: codec unit tests written first (status table, kind, attempt, nil-metadata clone); `TestWorkflowBeadResponseFromBeadEquivalence` pins the mapper byte-equal to the old inline expression - Fable adversarial review: behavior-preservation verified clean; the one blocker (speculative field over-reach) fixed by trimming Part of the raw-bead-leak cleanup epic. Refs `ga-rt98y1`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- internal/api/convoy_event_stream.go | 17 +- internal/api/convoy_sql.go | 13 +- internal/api/handler_convoy_dispatch.go | 79 ++---- internal/api/handler_convoy_dispatch_test.go | 90 ++++++ internal/molecule/workflow_bead.go | 146 ++++++++++ internal/molecule/workflow_bead_test.go | 275 +++++++++++++++++++ 6 files changed, 544 insertions(+), 76 deletions(-) create mode 100644 internal/molecule/workflow_bead.go create mode 100644 internal/molecule/workflow_bead_test.go diff --git a/internal/api/convoy_event_stream.go b/internal/api/convoy_event_stream.go index bcaa4523df..d5f808a0bc 100644 --- a/internal/api/convoy_event_stream.go +++ b/internal/api/convoy_event_stream.go @@ -365,20 +365,9 @@ func projectWorkflowEvent(state State, event events.Event) *workflowEventProject WorkflowSeq: event.Seq, EventTS: event.Ts.UTC().Format(time.RFC3339), EventType: event.Type, - Bead: workflowBeadResponse{ - ID: bead.ID, - Title: bead.Title, - Status: workflowStatus(bead), - Kind: workflowKind(bead), - StepRef: strings.TrimSpace(bead.Metadata[beadmeta.StepRefMetadataKey]), - Attempt: workflowAttempt(bead), - LogicalBeadID: strings.TrimSpace(bead.Metadata[beadmeta.LogicalBeadIDMetadataKey]), - ScopeRef: strings.TrimSpace(bead.Metadata[beadmeta.ScopeRefMetadataKey]), - Assignee: strings.TrimSpace(bead.Assignee), - Metadata: cloneStringMap(bead.Metadata), - }, - ChangedFields: changedFields, - LogicalNodeID: logicalNodeID, + Bead: workflowBeadResponseFromBead(bead), + ChangedFields: changedFields, + LogicalNodeID: logicalNodeID, } if event.Type == events.BeadUpdated { projection.RequiresResync = true diff --git a/internal/api/convoy_sql.go b/internal/api/convoy_sql.go index 06cf26bab4..ece6883e85 100644 --- a/internal/api/convoy_sql.go +++ b/internal/api/convoy_sql.go @@ -480,18 +480,7 @@ func (s *Server) tryFullWorkflowSQL(workflowID, fallbackScopeKind, fallbackScope storeRef := chosen.info.ref beadResponses := make([]workflowBeadResponse, 0, len(workflowBeads)) for _, bead := range workflowBeads { - beadResponses = append(beadResponses, workflowBeadResponse{ - ID: bead.ID, - Title: bead.Title, - Status: workflowStatus(bead), - Kind: workflowKind(bead), - StepRef: strings.TrimSpace(bead.Metadata[beadmeta.StepRefMetadataKey]), - Attempt: workflowAttempt(bead), - LogicalBeadID: strings.TrimSpace(bead.Metadata[beadmeta.LogicalBeadIDMetadataKey]), - ScopeRef: strings.TrimSpace(bead.Metadata[beadmeta.ScopeRefMetadataKey]), - Assignee: strings.TrimSpace(bead.Assignee), - Metadata: cloneStringMap(bead.Metadata), - }) + beadResponses = append(beadResponses, workflowBeadResponseFromBead(bead)) } snapshot := &workflowSnapshotResponse{ diff --git a/internal/api/handler_convoy_dispatch.go b/internal/api/handler_convoy_dispatch.go index 3b84d592db..7f79de9a23 100644 --- a/internal/api/handler_convoy_dispatch.go +++ b/internal/api/handler_convoy_dispatch.go @@ -8,6 +8,7 @@ import ( "github.com/gastownhall/gascity/internal/beadmeta" "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/molecule" ) var errWorkflowNotFound = errors.New("workflow not found") @@ -233,18 +234,7 @@ func (s *Server) snapshotFromStore(info workflowStoreInfo, root beads.Bead, fall beadResponses := make([]workflowBeadResponse, 0, len(workflowBeads)) for _, bead := range workflowBeads { - beadResponses = append(beadResponses, workflowBeadResponse{ - ID: bead.ID, - Title: bead.Title, - Status: workflowStatus(bead), - Kind: workflowKind(bead), - StepRef: strings.TrimSpace(bead.Metadata[beadmeta.StepRefMetadataKey]), - Attempt: workflowAttempt(bead), - LogicalBeadID: strings.TrimSpace(bead.Metadata[beadmeta.LogicalBeadIDMetadataKey]), - ScopeRef: strings.TrimSpace(bead.Metadata[beadmeta.ScopeRefMetadataKey]), - Assignee: strings.TrimSpace(bead.Assignee), - Metadata: cloneStringMap(bead.Metadata), - }) + beadResponses = append(beadResponses, workflowBeadResponseFromBead(bead)) } snapshot := &workflowSnapshotResponse{ @@ -475,12 +465,7 @@ func workflowAttempt(bead beads.Bead) *int { } func workflowAttemptValue(bead beads.Bead) int { - raw := strings.TrimSpace(bead.Metadata[beadmeta.AttemptMetadataKey]) - if raw == "" { - return 0 - } - v, _ := strconv.Atoi(raw) - return v + return molecule.WorkflowAttempt(bead) } func isTerminalWorkflowStatus(status string) bool { @@ -543,41 +528,35 @@ func cloneStringMap(src map[string]string) map[string]string { } func workflowKind(bead beads.Bead) string { - if bead.Metadata != nil { - if kind := strings.TrimSpace(bead.Metadata[beadmeta.KindMetadataKey]); kind != "" { - return kind - } - } - return strings.TrimSpace(bead.Type) + return molecule.WorkflowKind(bead) } func workflowStatus(bead beads.Bead) string { - outcome := strings.TrimSpace(bead.Metadata[beadmeta.OutcomeMetadataKey]) - hasAssignment := strings.TrimSpace(bead.Assignee) != "" - switch strings.TrimSpace(bead.Status) { - case "closed": - switch outcome { - case beadmeta.OutcomeFail: - return "failed" - case beadmeta.OutcomeSkipped: - return "skipped" - } - return "completed" - case "in_progress": - if hasAssignment { - return "active" - } - return "pending" - case "open": - return "pending" - default: - switch outcome { - case beadmeta.OutcomeFail: - return "failed" - case beadmeta.OutcomeSkipped: - return "skipped" - } - return strings.TrimSpace(bead.Status) + return molecule.WorkflowStatus(bead) +} + +// workflowBeadResponseFromBead maps a workflow bead onto its snapshot response +// node through the molecule.WorkflowBead codec — the single mapping shared by the +// snapshot, SQL-fast-path, and event-projection build loops. The codec already +// clones the metadata map (preserving nil -> nil for the wire's "metadata": +// null), so cloneStringMap is not called here. +func workflowBeadResponseFromBead(bead beads.Bead) workflowBeadResponse { + wb := molecule.WorkflowBeadFromBead(bead) + var attempt *int + if wb.Attempt > 0 { + attempt = &wb.Attempt + } + return workflowBeadResponse{ + ID: wb.ID, + Title: wb.Title, + Status: wb.Status, + Kind: wb.Kind, + StepRef: wb.StepRef, + Attempt: attempt, + LogicalBeadID: wb.LogicalBeadID, + ScopeRef: wb.ScopeRef, + Assignee: wb.Assignee, + Metadata: wb.Metadata, } } diff --git a/internal/api/handler_convoy_dispatch_test.go b/internal/api/handler_convoy_dispatch_test.go index 0603c03b9e..9fb4378aff 100644 --- a/internal/api/handler_convoy_dispatch_test.go +++ b/internal/api/handler_convoy_dispatch_test.go @@ -11,9 +11,11 @@ import ( "net/http/httptest" "os" "path/filepath" + "reflect" "strings" "testing" + "github.com/gastownhall/gascity/internal/beadmeta" "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/events" @@ -1042,6 +1044,94 @@ func TestWorkflowStatusTreatsSkippedAsSkipped(t *testing.T) { } } +// oldInlineWorkflowBeadResponse reproduces the pre-refactor inline +// struct-literal construction that the three build loops used, so +// TestWorkflowBeadResponseFromBeadEquivalence can pin the new codec-backed +// mapper against it field-for-field. +func oldInlineWorkflowBeadResponse(bead beads.Bead) workflowBeadResponse { + return workflowBeadResponse{ + ID: bead.ID, + Title: bead.Title, + Status: workflowStatus(bead), + Kind: workflowKind(bead), + StepRef: strings.TrimSpace(bead.Metadata[beadmeta.StepRefMetadataKey]), + Attempt: workflowAttempt(bead), + LogicalBeadID: strings.TrimSpace(bead.Metadata[beadmeta.LogicalBeadIDMetadataKey]), + ScopeRef: strings.TrimSpace(bead.Metadata[beadmeta.ScopeRefMetadataKey]), + Assignee: strings.TrimSpace(bead.Assignee), + Metadata: cloneStringMap(bead.Metadata), + } +} + +func TestWorkflowBeadResponseFromBeadEquivalence(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + bead beads.Bead + }{ + { + name: "fully populated with padded metadata", + bead: beads.Bead{ + ID: "step-1", + Title: "Do the thing", + Status: "in_progress", + Assignee: " worker-1 ", + Type: "task", + Metadata: map[string]string{ + beadmeta.KindMetadataKey: " run ", + beadmeta.OutcomeMetadataKey: "", + beadmeta.AttemptMetadataKey: " 2 ", + beadmeta.StepRefMetadataKey: " iteration.1.review ", + beadmeta.LogicalBeadIDMetadataKey: " logical-9 ", + beadmeta.ScopeRefMetadataKey: " gascity ", + }, + }, + }, + { + name: "minimal bead with nil metadata", + bead: beads.Bead{ID: "root-2", Title: "bare"}, + }, + { + name: "closed with fail outcome", + bead: beads.Bead{ + ID: "step-3", + Title: "failed step", + Status: "closed", + Metadata: map[string]string{beadmeta.OutcomeMetadataKey: beadmeta.OutcomeFail}, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := workflowBeadResponseFromBead(tc.bead) + want := oldInlineWorkflowBeadResponse(tc.bead) + if !reflect.DeepEqual(got, want) { + t.Fatalf("workflowBeadResponseFromBead mismatch:\n got=%#v\nwant=%#v", got, want) + } + // Attempt pointer semantics: nil when unset, non-nil when > 0. + if (got.Attempt == nil) != (want.Attempt == nil) { + t.Fatalf("attempt pointer nilness mismatch: got=%v want=%v", got.Attempt, want.Attempt) + } + // nil source metadata must stay nil so the wire keeps "metadata": null. + if tc.bead.Metadata == nil && got.Metadata != nil { + t.Fatalf("nil metadata projected to non-nil: %#v", got.Metadata) + } + }) + } + + // Clone independence: mutating the source metadata after projection must + // not change the response map. + src := map[string]string{beadmeta.KindMetadataKey: "workflow"} + resp := workflowBeadResponseFromBead(beads.Bead{ID: "root-1", Metadata: src}) + src[beadmeta.KindMetadataKey] = "mutated" + if resp.Metadata[beadmeta.KindMetadataKey] != "workflow" { + t.Fatalf("response metadata not independent of source: %q", resp.Metadata[beadmeta.KindMetadataKey]) + } +} + func TestWorkflowGetRejectsNonWorkflowRoot(t *testing.T) { state := newFakeState(t) cityStore := beads.NewMemStore() diff --git a/internal/molecule/workflow_bead.go b/internal/molecule/workflow_bead.go new file mode 100644 index 0000000000..bb503925a0 --- /dev/null +++ b/internal/molecule/workflow_bead.go @@ -0,0 +1,146 @@ +package molecule + +import ( + "strconv" + "strings" + + "github.com/gastownhall/gascity/internal/beadmeta" + "github.com/gastownhall/gascity/internal/beads" +) + +// WorkflowBead is the typed projection of a workflow root or step bead: the +// derived status/kind/attempt plus the gc.* metadata a snapshot presentation +// consumes, read once through a confined codec instead of cracking the raw +// bead inline at every call site. +// +// It is the workflow-domain analog of session.InfoFromPersistedBead's Info: +// molecule is the package that materializes a formula run as a root bead plus +// child step beads, so it owns what a workflow bead means. WorkflowBeadFromBead +// is pure, side-effect-free, and backend-invariant — it reads only stored bead +// fields, so a bead round-trips to the same WorkflowBead whether it was persisted +// to bd, sqlite, or postgres. +// +// Not to be confused with internal/runproj.toRunSnapshotBead (detail.go) and +// internal/runproj.fromBead (summary.go). Those are a deliberately DIFFERENT +// projection: a byte-parity port of the TS golden run-view generator that reads +// the raw bead status (not this file's derived pending/active/completed/failed/ +// skipped vocabulary), uses b.Ref for the step ref (not gc.step_ref), and honors +// gc.original_kind over b.Type (not gc.kind). Those semantics are locked by +// detail_golden_test.go / detail_parity_test.go. Do NOT merge the two codecs: +// unifying them would break golden parity or force a dual-mode codec. +type WorkflowBead struct { + // ID and Title mirror the bead's identity fields verbatim. + ID string + Title string + // Status is the derived presentation status (see WorkflowStatus). + Status string + // Kind is the workflow kind (see WorkflowKind). + Kind string + // StepRef is the trimmed gc.step_ref metadata. + StepRef string + // Attempt is the parsed gc.attempt metadata (0 when unset or unparseable). + Attempt int + // LogicalBeadID is the trimmed gc.logical_bead_id metadata. + LogicalBeadID string + // ScopeRef is the trimmed gc.scope_ref metadata. + ScopeRef string + // Assignee is the trimmed bead assignee. + Assignee string + // Metadata is an independent clone of the bead metadata. A nil source map + // stays nil so the wire keeps emitting "metadata": null for nil-metadata + // beads. + Metadata map[string]string +} + +// WorkflowBeadFromBead projects a workflow root or step bead onto WorkflowBead. +// It composes WorkflowStatus/WorkflowKind/WorkflowAttempt and trims the gc.* +// metadata scalars, cloning the metadata map so callers never share the bead's +// backing storage. See WorkflowBead for the purity and runproj-divergence notes. +func WorkflowBeadFromBead(b beads.Bead) WorkflowBead { + return WorkflowBead{ + ID: b.ID, + Title: b.Title, + Status: WorkflowStatus(b), + Kind: WorkflowKind(b), + StepRef: strings.TrimSpace(b.Metadata[beadmeta.StepRefMetadataKey]), + Attempt: WorkflowAttempt(b), + LogicalBeadID: strings.TrimSpace(b.Metadata[beadmeta.LogicalBeadIDMetadataKey]), + ScopeRef: strings.TrimSpace(b.Metadata[beadmeta.ScopeRefMetadataKey]), + Assignee: strings.TrimSpace(b.Assignee), + Metadata: cloneMetadata(b.Metadata), + } +} + +// WorkflowStatus derives a workflow bead's presentation status from its bead +// status and gc.outcome metadata: closed+fail -> "failed", closed+skipped -> +// "skipped", closed -> "completed", in_progress with an assignee -> "active", +// in_progress or open -> "pending". Any other raw status honors gc.outcome +// (fail/skipped) and otherwise passes through trimmed. It is exported separately +// so hot loops can derive status without paying the full-projection metadata +// clone. +func WorkflowStatus(b beads.Bead) string { + outcome := strings.TrimSpace(b.Metadata[beadmeta.OutcomeMetadataKey]) + hasAssignment := strings.TrimSpace(b.Assignee) != "" + switch strings.TrimSpace(b.Status) { + case "closed": + switch outcome { + case beadmeta.OutcomeFail: + return "failed" + case beadmeta.OutcomeSkipped: + return "skipped" + } + return "completed" + case "in_progress": + if hasAssignment { + return "active" + } + return "pending" + case "open": + return "pending" + default: + switch outcome { + case beadmeta.OutcomeFail: + return "failed" + case beadmeta.OutcomeSkipped: + return "skipped" + } + return strings.TrimSpace(b.Status) + } +} + +// WorkflowKind returns the workflow kind: the trimmed gc.kind metadata when +// present, falling back to the trimmed bead Type. +func WorkflowKind(b beads.Bead) string { + if b.Metadata != nil { + if kind := strings.TrimSpace(b.Metadata[beadmeta.KindMetadataKey]); kind != "" { + return kind + } + } + return strings.TrimSpace(b.Type) +} + +// WorkflowAttempt returns the parsed gc.attempt metadata as an int, or 0 when +// the metadata is empty or non-numeric. The API mapper converts 0 to the wire's +// omitted *int. +func WorkflowAttempt(b beads.Bead) int { + raw := strings.TrimSpace(b.Metadata[beadmeta.AttemptMetadataKey]) + if raw == "" { + return 0 + } + v, _ := strconv.Atoi(raw) + return v +} + +// cloneMetadata returns an independent copy of a metadata map, preserving +// nil -> nil so a nil-metadata bead projects to nil metadata (and the wire keeps +// emitting "metadata": null). Port of api.cloneStringMap. +func cloneMetadata(src map[string]string) map[string]string { + if src == nil { + return nil + } + dst := make(map[string]string, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} diff --git a/internal/molecule/workflow_bead_test.go b/internal/molecule/workflow_bead_test.go new file mode 100644 index 0000000000..6f5e045216 --- /dev/null +++ b/internal/molecule/workflow_bead_test.go @@ -0,0 +1,275 @@ +package molecule + +import ( + "reflect" + "testing" + + "github.com/gastownhall/gascity/internal/beadmeta" + "github.com/gastownhall/gascity/internal/beads" +) + +func TestWorkflowStatus(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + bead beads.Bead + want string + }{ + // Direct ports of the api workflowStatus subtests so the body move is + // oracle-checked against the pre-refactor behavior. + { + name: "open assigned is pending", + bead: beads.Bead{ + Status: "open", + Assignee: "assigned-role", + Metadata: map[string]string{"gc.routed_to": "routed-role"}, + }, + want: "pending", + }, + { + name: "in_progress unassigned is pending", + bead: beads.Bead{Status: "in_progress"}, + want: "pending", + }, + { + name: "in_progress routed-only is pending", + bead: beads.Bead{ + Status: "in_progress", + Metadata: map[string]string{"gc.routed_to": "routed-role"}, + }, + want: "pending", + }, + { + name: "closed skipped is skipped", + bead: beads.Bead{ + Status: "closed", + Metadata: map[string]string{beadmeta.OutcomeMetadataKey: beadmeta.OutcomeSkipped}, + }, + want: "skipped", + }, + // Full coverage of the remaining switch arms. + { + name: "in_progress assigned is active", + bead: beads.Bead{Status: "in_progress", Assignee: "worker-1"}, + want: "active", + }, + { + name: "closed plain is completed", + bead: beads.Bead{Status: "closed"}, + want: "completed", + }, + { + name: "closed fail is failed", + bead: beads.Bead{ + Status: "closed", + Metadata: map[string]string{beadmeta.OutcomeMetadataKey: beadmeta.OutcomeFail}, + }, + want: "failed", + }, + { + name: "unknown status passes through", + bead: beads.Bead{Status: "quarantined"}, + want: "quarantined", + }, + { + name: "unknown status with fail outcome is failed", + bead: beads.Bead{ + Status: "quarantined", + Metadata: map[string]string{beadmeta.OutcomeMetadataKey: beadmeta.OutcomeFail}, + }, + want: "failed", + }, + { + name: "unknown status with skipped outcome is skipped", + bead: beads.Bead{ + Status: "quarantined", + Metadata: map[string]string{beadmeta.OutcomeMetadataKey: beadmeta.OutcomeSkipped}, + }, + want: "skipped", + }, + { + name: "whitespace-padded status and outcome are trimmed", + bead: beads.Bead{ + Status: " closed ", + Metadata: map[string]string{beadmeta.OutcomeMetadataKey: " fail "}, + }, + want: "failed", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := WorkflowStatus(tc.bead); got != tc.want { + t.Fatalf("WorkflowStatus(%q) = %q, want %q", tc.name, got, tc.want) + } + }) + } +} + +func TestWorkflowKind(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + bead beads.Bead + want string + }{ + { + name: "gc.kind wins over Type", + bead: beads.Bead{ + Type: "task", + Metadata: map[string]string{beadmeta.KindMetadataKey: "workflow"}, + }, + want: "workflow", + }, + { + name: "falls back to Type when gc.kind absent", + bead: beads.Bead{Type: "task"}, + want: "task", + }, + { + name: "falls back to Type when gc.kind blank", + bead: beads.Bead{ + Type: "task", + Metadata: map[string]string{beadmeta.KindMetadataKey: " "}, + }, + want: "task", + }, + { + name: "trims padded gc.kind", + bead: beads.Bead{ + Type: "task", + Metadata: map[string]string{beadmeta.KindMetadataKey: " workflow "}, + }, + want: "workflow", + }, + { + name: "trims padded Type fallback", + bead: beads.Bead{Type: " run "}, + want: "run", + }, + { + name: "nil metadata is safe", + bead: beads.Bead{Type: "run"}, + want: "run", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := WorkflowKind(tc.bead); got != tc.want { + t.Fatalf("WorkflowKind(%q) = %q, want %q", tc.name, got, tc.want) + } + }) + } +} + +func TestWorkflowAttempt(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + bead beads.Bead + want int + }{ + { + name: "numeric attempt parses", + bead: beads.Bead{Metadata: map[string]string{beadmeta.AttemptMetadataKey: "3"}}, + want: 3, + }, + { + name: "missing attempt is zero", + bead: beads.Bead{}, + want: 0, + }, + { + name: "empty attempt is zero", + bead: beads.Bead{Metadata: map[string]string{beadmeta.AttemptMetadataKey: ""}}, + want: 0, + }, + { + name: "non-numeric attempt is zero", + bead: beads.Bead{Metadata: map[string]string{beadmeta.AttemptMetadataKey: "abc"}}, + want: 0, + }, + { + name: "padded numeric attempt is trimmed then parsed", + bead: beads.Bead{Metadata: map[string]string{beadmeta.AttemptMetadataKey: " 7 "}}, + want: 7, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := WorkflowAttempt(tc.bead); got != tc.want { + t.Fatalf("WorkflowAttempt(%q) = %d, want %d", tc.name, got, tc.want) + } + }) + } +} + +func TestWorkflowBeadFromBead_AllFields(t *testing.T) { + t.Parallel() + + b := beads.Bead{ + ID: "step-1", + Title: "Do the thing", + Status: "in_progress", + Assignee: " worker-1 ", + Type: "task", + Metadata: map[string]string{ + beadmeta.KindMetadataKey: " run ", + beadmeta.OutcomeMetadataKey: "", + beadmeta.AttemptMetadataKey: " 2 ", + beadmeta.StepRefMetadataKey: " iteration.1.review ", + beadmeta.LogicalBeadIDMetadataKey: " logical-9 ", + beadmeta.ScopeRefMetadataKey: " gascity ", + }, + } + + got := WorkflowBeadFromBead(b) + want := WorkflowBead{ + ID: "step-1", + Title: "Do the thing", + Status: "active", + Kind: "run", + StepRef: "iteration.1.review", + Attempt: 2, + LogicalBeadID: "logical-9", + ScopeRef: "gascity", + Assignee: "worker-1", + Metadata: b.Metadata, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("WorkflowBeadFromBead mismatch:\n got=%#v\nwant=%#v", got, want) + } +} + +func TestWorkflowBeadFromBead_MetadataClone(t *testing.T) { + t.Parallel() + + src := map[string]string{beadmeta.KindMetadataKey: "workflow"} + b := beads.Bead{ID: "root-1", Metadata: src} + + got := WorkflowBeadFromBead(b) + if got.Metadata == nil { + t.Fatal("expected cloned metadata, got nil") + } + // Mutating the source after projection must not change the clone. + src[beadmeta.KindMetadataKey] = "mutated" + if got.Metadata[beadmeta.KindMetadataKey] != "workflow" { + t.Fatalf("clone not independent: got %q", got.Metadata[beadmeta.KindMetadataKey]) + } + + // nil source metadata projects to nil (not an empty map) so the wire keeps + // emitting "metadata": null for nil-metadata beads. + nilGot := WorkflowBeadFromBead(beads.Bead{ID: "root-2"}) + if nilGot.Metadata != nil { + t.Fatalf("nil metadata projected to non-nil: %#v", nilGot.Metadata) + } +} From f42eff6dbabaae4ba083cb343ec2704ed6da61ad Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Wed, 8 Jul 2026 23:51:29 -0700 Subject: [PATCH 026/225] refactor(nudge): confine terminal-key vocabulary in Store.SweepStale, read via DecodeShadow (#4052) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Finishes the nudge Phase-2 migration so `cmd/gc` stops cracking raw nudge/session beads and stops re-stamping vocabulary the store owns. - **`nudgequeue.Store.SweepStale(beadID, closeReason, now)`** (new) confines the `gc-swept` terminal-key vocabulary (`state` / `terminal_reason` / `commit_boundary` / `terminal_at` / `close_reason`). `cmd/gc/nudge_mail_sweep.go` calls it instead of the inline `SetMetadataBatch` + `Close` block. - Nudge ids read via **`nudgequeue.DecodeShadow(b).ID`** (the read codec exported for exactly this) instead of `b.Metadata["nudge_id"]` at both sweep call sites. - `cmd/gc/cmd_nudge.go` reads `session_name`/`continuation_epoch` off a `session.InfoFromPersistedBead` projection; the raw-bead resolver `resolveNudgeTargetFromSessionBead` is deleted (single `session.Info` resolver now). ## Behavior preservation `SweepStale` mirrors the deleted inline sweep byte-for-byte: same five keys/values, same `SetMetadataBatch`-fail-skips-`Close` ordering, identical `"nudge %s: set metadata/close: %w"` error text, nil-receiver safe. `DecodeShadow(b).ID` and the `session.Info` mirrors (`SessionNameMetadata`, `ContinuationEpoch`) are verbatim untrimmed/trimmed reads matching the deleted forms. ## Verification - `gofmt` clean; `go vet ./internal/nudgequeue ./cmd/gc` clean; `go build ./...` clean - `go test ./internal/nudgequeue` green; `go test ./cmd/gc -run 'Nudge|Sweep'` green; `make test` exit 0 - TDD: `TestSweepStaleEmitsByteIdenticalWrites` (golden 5-key map + one Close), fail-skips-Close, nil-no-op; resolver equivalence goldens captured empirically before deleting the bead form — including a full-struct `reflect.DeepEqual` golden pinning derived fields (`cityPath`/`cityName`/parsed agent/resolved provider) - Fable adversarial review: approve (writes byte-identical); two nits applied (stale-comment reword + restored full-struct golden) Part of the raw-bead-leak cleanup epic. Refs `ga-4ikiot`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/gc/cmd_nudge.go | 40 ++-- cmd/gc/nudge_mail_sweep.go | 22 +-- cmd/gc/nudge_target_info_equiv_test.go | 253 +++++++++++++++++++------ internal/nudgequeue/store.go | 37 ++++ internal/nudgequeue/store_test.go | 90 +++++++++ 5 files changed, 338 insertions(+), 104 deletions(-) diff --git a/cmd/gc/cmd_nudge.go b/cmd/gc/cmd_nudge.go index d4f9dcc18f..52137f79af 100644 --- a/cmd/gc/cmd_nudge.go +++ b/cmd/gc/cmd_nudge.go @@ -1137,7 +1137,7 @@ func resolveNudgeTarget(identifier string, warningWriter ...io.Writer) (nudgeTar if getErr != nil { return nudgeTarget{}, getErr } - return resolveNudgeTargetFromSessionBead(cityPath, cfg, b), nil + return resolveNudgeTargetFromSessionInfo(cityPath, cfg, session.InfoFromPersistedBead(b)), nil } if !errors.Is(err, session.ErrSessionNotFound) { return nudgeTarget{}, err @@ -1147,8 +1147,8 @@ func resolveNudgeTarget(identifier string, warningWriter ...io.Writer) (nudgeTar } // nudgeTargetFields carries the pre-extracted session attributes buildNudgeTarget -// needs. Both resolvers (raw bead and typed session.Info) populate it from their -// own source, so the identity-resolution tail lives in exactly one place. +// needs. resolveNudgeTargetFromSessionInfo populates it from a session.Info +// projection, so the identity-resolution tail lives in exactly one place. type nudgeTargetFields struct { sessionID string sessionName string @@ -1162,30 +1162,11 @@ type nudgeTargetFields struct { continuationEpoch string } -func resolveNudgeTargetFromSessionBead(cityPath string, cfg *config.City, b beads.Bead) nudgeTarget { - sessionName := strings.TrimSpace(b.Metadata["session_name"]) - if sessionName == "" { - sessionName = sessionNameFromBeadID(b.ID) - } - return buildNudgeTarget(cityPath, cfg, nudgeTargetFields{ - sessionID: b.ID, - sessionName: sessionName, - alias: strings.TrimSpace(b.Metadata["alias"]), - agentName: strings.TrimSpace(b.Metadata["agent_name"]), - template: strings.TrimSpace(b.Metadata["template"]), - commonName: strings.TrimSpace(b.Metadata["common_name"]), - aliasHistory: session.AliasHistory(b.Metadata), - transport: strings.TrimSpace(b.Metadata["transport"]), - provider: strings.TrimSpace(b.Metadata["provider"]), - continuationEpoch: strings.TrimSpace(b.Metadata["continuation_epoch"]), - }) -} - -// resolveNudgeTargetFromSessionInfo is the typed front-door sibling of -// resolveNudgeTargetFromSessionBead: it reads the same session attributes from a -// session.Info projection instead of the raw bead. Note the transport source is -// i.TransportMetadata (the RAW value), not i.Transport (which normalizeTransport -// would make non-empty), so the found.Session fallback below fires identically. +// resolveNudgeTargetFromSessionInfo reads the session attributes buildNudgeTarget +// needs from a session.Info projection (the typed front door) rather than cracking +// the raw session bead. Note the transport source is i.TransportMetadata (the RAW +// value), not i.Transport (which normalizeTransport would make non-empty), so the +// found.Session fallback below fires identically. func resolveNudgeTargetFromSessionInfo(cityPath string, cfg *config.City, i session.Info) nudgeTarget { sessionName := strings.TrimSpace(i.SessionNameMetadata) if sessionName == "" { @@ -1484,14 +1465,15 @@ func withNudgeTargetFence(store beads.Store, target nudgeTarget) nudgeTarget { return target } for _, b := range open { - if b.Metadata["session_name"] != target.sessionName { + info := session.InfoFromPersistedBead(b) + if info.SessionNameMetadata != target.sessionName { continue } if target.sessionID == "" { target.sessionID = b.ID } if target.continuationEpoch == "" { - target.continuationEpoch = b.Metadata["continuation_epoch"] + target.continuationEpoch = info.ContinuationEpoch } return target } diff --git a/cmd/gc/nudge_mail_sweep.go b/cmd/gc/nudge_mail_sweep.go index 91c6b6a094..9e46d3902d 100644 --- a/cmd/gc/nudge_mail_sweep.go +++ b/cmd/gc/nudge_mail_sweep.go @@ -37,7 +37,8 @@ type nudgeMailSweepResult struct { // // Nudge candidates are open beads with label gc:nudge created before now-nudgeTTL // whose nudge_id is not present in nudgeState.Pending or nudgeState.InFlight. -// Terminal metadata is recorded before each close so the bead audit trail is intact. +// Terminal metadata is stamped via nudgequeue.Store.SweepStale before each close +// so the bead audit trail is intact. // // Mail candidates are open message beads with label "read" created before now-mailTTL. // @@ -54,6 +55,7 @@ func sweepStaleNudgeMail(nudgeStore beads.NudgesStore, mailStore beads.MailStore var beadErrs []error liveIDs := liveNudgeIDSet(nudgeState) + nq := nudgequeue.NewStore(nudgeStore) // Phase 1: close stale nudge beads. nudgeCutoff := now.Add(-nudgeTTL) @@ -74,22 +76,12 @@ func sweepStaleNudgeMail(nudgeStore beads.NudgesStore, mailStore beads.MailStore if b.Status != "open" { continue } - nudgeID := strings.TrimSpace(b.Metadata["nudge_id"]) + nudgeID := strings.TrimSpace(nudgequeue.DecodeShadow(b).ID) if nudgeID != "" && liveIDs[nudgeID] { continue } - if err := nudgeStore.SetMetadataBatch(b.ID, map[string]string{ - "state": "gc-swept", - "terminal_reason": "gc-swept-stale", - "commit_boundary": "gc-swept", - "terminal_at": now.UTC().Format(time.RFC3339), - "close_reason": nudgeMailSweepNudgeCloseReason, - }); err != nil { - beadErrs = append(beadErrs, fmt.Errorf("nudge %s: set metadata: %w", b.ID, err)) - continue - } - if err := nudgeStore.Close(b.ID); err != nil { - beadErrs = append(beadErrs, fmt.Errorf("nudge %s: close: %w", b.ID, err)) + if err := nq.SweepStale(b.ID, nudgeMailSweepNudgeCloseReason, now); err != nil { + beadErrs = append(beadErrs, err) continue } result.NudgeClosed++ @@ -155,7 +147,7 @@ func countStaleNudgeMail(nudgeStore beads.NudgesStore, mailStore beads.MailStore if b.Status != "open" { continue } - nudgeID := strings.TrimSpace(b.Metadata["nudge_id"]) + nudgeID := strings.TrimSpace(nudgequeue.DecodeShadow(b).ID) if nudgeID != "" && liveIDs[nudgeID] { continue } diff --git a/cmd/gc/nudge_target_info_equiv_test.go b/cmd/gc/nudge_target_info_equiv_test.go index b05782116c..88c0ef52e0 100644 --- a/cmd/gc/nudge_target_info_equiv_test.go +++ b/cmd/gc/nudge_target_info_equiv_test.go @@ -9,14 +9,18 @@ import ( "github.com/gastownhall/gascity/internal/session" ) -// TestNudgeTargetInfoEquivalence is the byte-identical oracle for migrating the -// nudge dispatcher off raw session beads. resolveNudgeTargetFromSessionInfo must -// produce exactly the nudgeTarget that resolveNudgeTargetFromSessionBead does for -// the same bead once projected through InfoFromPersistedBead. The transport cases -// specifically guard the fidelity trap: the resolver reads the RAW transport -// metadata (via i.TransportMetadata), not the normalized i.Transport, so the -// empty/whitespace-transport beads must still take the found.Session fallback. -func TestNudgeTargetInfoEquivalence(t *testing.T) { +// TestNudgeTargetFromSessionInfoGolden pins the behavior of +// resolveNudgeTargetFromSessionInfo directly. It began life as an equivalence +// oracle against the now-deleted raw-bead sibling +// (resolveNudgeTargetFromSessionBead); that oracle completed its migration +// purpose once the dispatcher and resolveNudgeTarget both moved onto the Info +// path, so this test now hard-codes the goldens the oracle proved. +// +// The transport cases still guard the fidelity trap: the resolver reads the RAW +// transport metadata (via i.TransportMetadata), not the normalized i.Transport, +// so the empty- and whitespace-transport beads must still take the found.Session +// fallback ("tmux"). ga-no-sn / ga-bare guard the sessionNameFromBeadID fallback. +func TestNudgeTargetFromSessionInfoGolden(t *testing.T) { cityPath := "/tmp/test-city" cfg := &config.City{ Workspace: config.Workspace{Provider: "claude"}, @@ -25,74 +29,203 @@ func TestNudgeTargetInfoEquivalence(t *testing.T) { }, } - beadsIn := []beads.Bead{ + cases := []struct { + name string + bead beads.Bead + wantSessionID string + wantSessionName string + wantIdentity string + wantAlias string + wantAliasHistory []string + wantTransport string + wantProvider string + wantContinuationEpoch string + }{ { - ID: "ga-full", - Type: session.BeadType, - Title: "full", - Labels: []string{session.LabelSession}, - Metadata: map[string]string{ - "template": "frontend/worker", - "agent_name": "frontend/worker-1", - "common_name": "the-worker", - "alias": "worker-alias", - "provider": "claude", - "transport": "acp", - "session_name": "worker-session", - "continuation_epoch": "3", - "alias_history": "old-alias,older-alias", + name: "full", + bead: beads.Bead{ + ID: "ga-full", + Type: session.BeadType, + Title: "full", + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "template": "frontend/worker", + "agent_name": "frontend/worker-1", + "common_name": "the-worker", + "alias": "worker-alias", + "provider": "claude", + "transport": "acp", + "session_name": "worker-session", + "continuation_epoch": "3", + "alias_history": "old-alias,older-alias", + }, }, + wantSessionID: "ga-full", + wantSessionName: "worker-session", + wantIdentity: "frontend/worker-1", + wantAlias: "worker-alias", + wantAliasHistory: []string{"old-alias", "older-alias"}, + wantTransport: "acp", + wantProvider: "claude", + wantContinuationEpoch: "3", }, { // Empty transport → resolver must fall back to the agent's Session. - ID: "ga-empty-transport", - Type: session.BeadType, - Title: "empty-transport", - Labels: []string{session.LabelSession}, - Metadata: map[string]string{ - "template": "worker", - "provider": "claude", - "session_name": "empty-transport-session", + name: "empty-transport", + bead: beads.Bead{ + ID: "ga-empty-transport", + Type: session.BeadType, + Title: "empty-transport", + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "template": "worker", + "provider": "claude", + "session_name": "empty-transport-session", + }, }, + wantSessionID: "ga-empty-transport", + wantSessionName: "empty-transport-session", + wantIdentity: "worker", + wantTransport: "tmux", + wantProvider: "claude", }, { - // Whitespace transport → TrimSpace on both raw and Info must agree. - ID: "ga-ws-transport", - Type: session.BeadType, - Title: "ws-transport", - Labels: []string{session.LabelSession}, - Metadata: map[string]string{ - "template": "worker", - "transport": " ", - "provider": "claude", - "session_name": "ws-transport-session", + // Whitespace transport → TrimSpace on the raw value yields "", so the + // found.Session fallback fires identically to the empty case. + name: "ws-transport", + bead: beads.Bead{ + ID: "ga-ws-transport", + Type: session.BeadType, + Title: "ws-transport", + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "template": "worker", + "transport": " ", + "provider": "claude", + "session_name": "ws-transport-session", + }, }, + wantSessionID: "ga-ws-transport", + wantSessionName: "ws-transport-session", + wantIdentity: "worker", + wantTransport: "tmux", + wantProvider: "claude", }, { - // No session_name → sessionNameFromBeadID fallback on both sides. - ID: "ga-no-sn", - Type: session.BeadType, - Title: "no-sn", - Labels: []string{session.LabelSession}, - Metadata: map[string]string{ - "template": "scribe", + // No session_name → sessionNameFromBeadID fallback; unknown template + // resolves no agent, so transport/provider stay their raw (empty) values. + name: "no-session-name", + bead: beads.Bead{ + ID: "ga-no-sn", + Type: session.BeadType, + Title: "no-sn", + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "template": "scribe", + }, }, + wantSessionID: "ga-no-sn", + wantSessionName: "s-ga-no-sn", + wantIdentity: "scribe", }, { - // Bare metadata, only an ID. - ID: "ga-bare", - Type: session.BeadType, - Title: "bare", - Labels: []string{session.LabelSession}, - Metadata: map[string]string{}, + // Bare metadata, only an ID → identity falls through to the session name. + name: "bare", + bead: beads.Bead{ + ID: "ga-bare", + Type: session.BeadType, + Title: "bare", + Labels: []string{session.LabelSession}, + Metadata: map[string]string{}, + }, + wantSessionID: "ga-bare", + wantSessionName: "s-ga-bare", + wantIdentity: "s-ga-bare", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := resolveNudgeTargetFromSessionInfo(cityPath, cfg, session.InfoFromPersistedBead(tc.bead)) + + if got.sessionID != tc.wantSessionID { + t.Errorf("sessionID = %q, want %q", got.sessionID, tc.wantSessionID) + } + if got.sessionName != tc.wantSessionName { + t.Errorf("sessionName = %q, want %q", got.sessionName, tc.wantSessionName) + } + if got.identity != tc.wantIdentity { + t.Errorf("identity = %q, want %q", got.identity, tc.wantIdentity) + } + if got.alias != tc.wantAlias { + t.Errorf("alias = %q, want %q", got.alias, tc.wantAlias) + } + if !reflect.DeepEqual(got.aliasHistory, tc.wantAliasHistory) { + t.Errorf("aliasHistory = %#v, want %#v", got.aliasHistory, tc.wantAliasHistory) + } + if got.transport != tc.wantTransport { + t.Errorf("transport = %q, want %q", got.transport, tc.wantTransport) + } + provider := "" + if got.resolved != nil { + provider = got.resolved.Name + } + if provider != tc.wantProvider { + t.Errorf("resolved provider = %q, want %q", provider, tc.wantProvider) + } + if got.continuationEpoch != tc.wantContinuationEpoch { + t.Errorf("continuationEpoch = %q, want %q", got.continuationEpoch, tc.wantContinuationEpoch) + } + }) + } +} + +// TestNudgeTargetFromSessionInfoFullGolden pins the ENTIRE nudgeTarget for the +// richest bead via reflect.DeepEqual, so a regression in any field buildNudgeTarget +// derives — including the ones the field-level cases above do not assert +// (cityPath, cityName, cfg wiring, and the parsed agent value) — is still caught. +func TestNudgeTargetFromSessionInfoFullGolden(t *testing.T) { + cityPath := "/tmp/test-city" + cfg := &config.City{ + Workspace: config.Workspace{Provider: "claude"}, + Agents: []config.Agent{ + {Name: "worker", Provider: "claude", Session: "tmux"}, + }, + } + b := beads.Bead{ + ID: "ga-full", + Type: session.BeadType, + Title: "full", + Labels: []string{session.LabelSession}, + Metadata: map[string]string{ + "template": "frontend/worker", + "agent_name": "frontend/worker-1", + "common_name": "the-worker", + "alias": "worker-alias", + "provider": "claude", + "transport": "acp", + "session_name": "worker-session", + "continuation_epoch": "3", + "alias_history": "old-alias,older-alias", }, } - for _, b := range beadsIn { - want := resolveNudgeTargetFromSessionBead(cityPath, cfg, b) - got := resolveNudgeTargetFromSessionInfo(cityPath, cfg, session.InfoFromPersistedBead(b)) - if !reflect.DeepEqual(want, got) { - t.Errorf("bead %q: resolveNudgeTargetFromSessionInfo = %#v, want (bead form) %#v", b.ID, got, want) - } + got := resolveNudgeTargetFromSessionInfo(cityPath, cfg, session.InfoFromPersistedBead(b)) + want := nudgeTarget{ + cityPath: "/tmp/test-city", + cityName: "test-city", + cfg: cfg, + alias: "worker-alias", + aliasHistory: []string{"old-alias", "older-alias"}, + identity: "frontend/worker-1", + transport: "acp", + agent: config.Agent{Name: "worker-1", Dir: "frontend"}, + resolved: &config.ResolvedProvider{Name: "claude"}, + sessionID: "ga-full", + continuationEpoch: "3", + sessionName: "worker-session", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("full nudgeTarget mismatch:\n got=%#v\nwant=%#v", got, want) } } diff --git a/internal/nudgequeue/store.go b/internal/nudgequeue/store.go index 784b66e751..e26daaddff 100644 --- a/internal/nudgequeue/store.go +++ b/internal/nudgequeue/store.go @@ -3,6 +3,7 @@ package nudgequeue import ( "encoding/json" "errors" + "fmt" "strconv" "strings" "time" @@ -280,6 +281,42 @@ func (s *Store) RollbackEnqueue(beadID string) error { return errs } +// SweepStale stamps the gc-swept terminal vocabulary on a stale nudge shadow bead +// past the gc retention window and closes it. It is the retention-sweep sibling of +// Terminalize/RollbackEnqueue: the gc-swept terminal-key vocabulary (state / +// terminal_reason / commit_boundary / terminal_at / close_reason) is now confined +// here alongside Terminalize's canonicalCloseReason vocabulary, so the cmd/gc +// sweep no longer re-stamps these keys inline. closeReason is caller-supplied — +// cmd/gc keeps ownership of the human message constant — and must satisfy the +// >=20-char validation.on-close floor. +// +// SweepStale emits byte-identical bead writes to the prior inline stamp+close +// block in cmd/gc/nudge_mail_sweep.go: a single SetMetadataBatch with the same +// five keys, then Close. A SetMetadataBatch failure returns without closing, +// matching the sweep's continue-without-close semantics, and both error strings +// preserve the caller's prior "nudge %s: set metadata/close: %w" text so +// joined-error assertions keep passing. Unlike Terminalize it adds no missing-bead +// tolerance, matching the inline sweep it replaces. +func (s *Store) SweepStale(beadID, closeReason string, now time.Time) error { + if s == nil || s.store.Store == nil { + return nil + } + update := map[string]string{ + "state": "gc-swept", + "terminal_reason": "gc-swept-stale", + "commit_boundary": "gc-swept", + "terminal_at": now.UTC().Format(time.RFC3339), + "close_reason": closeReason, + } + if err := s.store.SetMetadataBatch(beadID, update); err != nil { + return fmt.Errorf("nudge %s: set metadata: %w", beadID, err) + } + if err := s.store.Close(beadID); err != nil { + return fmt.Errorf("nudge %s: close: %w", beadID, err) + } + return nil +} + // Find returns the OPEN (or terminal-but-decodable) nudge shadow for nudgeID as // a typed NudgeShadow, plus whether one was found. It is the existence gate used // by wait readiness; callers receive the decoded view rather than a raw bead. diff --git a/internal/nudgequeue/store_test.go b/internal/nudgequeue/store_test.go index 613af154cb..87aa32d968 100644 --- a/internal/nudgequeue/store_test.go +++ b/internal/nudgequeue/store_test.go @@ -2,7 +2,9 @@ package nudgequeue import ( "encoding/json" + "errors" "reflect" + "strings" "testing" "time" @@ -211,6 +213,94 @@ func TestRollbackEnqueueEmitsByteIdenticalWrites(t *testing.T) { } } +// TestSweepStaleEmitsByteIdenticalWrites proves SweepStale stamps the exact +// five-key gc-swept terminal map and then closes the bead — the byte-identical +// contract for the prior inline stamp+close block in cmd/gc/nudge_mail_sweep.go. +func TestSweepStaleEmitsByteIdenticalWrites(t *testing.T) { + st, rec := newRecordingNudgeStore(t) + beadID, _, err := st.Save(sampleNudgeItem()) + if err != nil { + t.Fatalf("Save err = %v", err) + } + rec.Reset() + + now := time.Date(2026, 6, 2, 9, 30, 0, 0, time.UTC) + const closeReason = "nudge gc-swept: stale nudge bead past gc retention window" + if err := st.SweepStale(beadID, closeReason, now); err != nil { + t.Fatalf("SweepStale err = %v", err) + } + + batches := rec.CallsForOp("SetMetadataBatch") + if len(batches) != 1 { + t.Fatalf("SetMetadataBatch calls = %d, want 1", len(batches)) + } + wantUpdate := map[string]string{ + "state": "gc-swept", + "terminal_reason": "gc-swept-stale", + "commit_boundary": "gc-swept", + "terminal_at": "2026-06-02T09:30:00Z", + "close_reason": closeReason, + } + if !reflect.DeepEqual(batches[0].Metadata, wantUpdate) { + t.Errorf("update map mismatch:\n got=%#v\nwant=%#v", batches[0].Metadata, wantUpdate) + } + if batches[0].ID != beadID { + t.Errorf("SetMetadataBatch id = %q, want %q", batches[0].ID, beadID) + } + closes := rec.CallsForOp("Close") + if len(closes) != 1 || closes[0].ID != beadID { + t.Errorf("Close calls = %+v, want one close of %q", closes, beadID) + } +} + +// failingSetMetadataBatchStore wraps a beads.Store but fails every +// SetMetadataBatch, so a test can prove SweepStale skips Close when the metadata +// write fails. +type failingSetMetadataBatchStore struct { + beads.Store + err error +} + +func (f failingSetMetadataBatchStore) SetMetadataBatch(string, map[string]string) error { + return f.err +} + +// TestSweepStaleSetMetadataFailureSkipsClose proves a failed SetMetadataBatch +// returns a bead-ID-bearing error and never reaches Close, preserving the sweep's +// current continue-without-close semantics. +func TestSweepStaleSetMetadataFailureSkipsClose(t *testing.T) { + rec := beadstest.NewRecordingStore(beads.NewMemStore()) + failing := failingSetMetadataBatchStore{Store: rec, err: errors.New("batch boom")} + st := NewStore(beads.NudgesStore{Store: failing}) + + err := st.SweepStale("nb-fail", "nudge gc-swept: stale nudge bead past gc retention window", time.Now().UTC()) + if err == nil { + t.Fatalf("SweepStale err = nil, want non-nil on SetMetadataBatch failure") + } + if !strings.Contains(err.Error(), "nb-fail") || !strings.Contains(err.Error(), "set metadata") { + t.Errorf("err = %q, want it to contain the bead id and \"set metadata\"", err) + } + if n := len(rec.CallsForOp("Close")); n != 0 { + t.Errorf("Close calls = %d, want 0 (SetMetadataBatch failure must skip Close)", n) + } +} + +// TestSweepStaleNilStoreIsNoOp pins the nil-safety contract shared by every Store +// method: a nil *Store and a Store over a nil embedded store both no-op. +func TestSweepStaleNilStoreIsNoOp(t *testing.T) { + const reason = "nudge gc-swept: stale nudge bead past gc retention window" + now := time.Now().UTC() + + var s *Store // nil receiver: shadow bead store unavailable + if err := s.SweepStale("gc-1", reason, now); err != nil { + t.Errorf("SweepStale on nil store = %v, want nil no-op", err) + } + empty := NewStore(beads.NudgesStore{}) // Store over a nil embedded store + if err := empty.SweepStale("gc-1", reason, now); err != nil { + t.Errorf("SweepStale on nil embedded store = %v, want nil no-op", err) + } +} + // TestFindReturnsTypedShadow proves Find returns a decoded NudgeShadow (open // bead) and FindIncludingTerminal reads the controller-stamped terminal fields // off a closed bead. From 783da3ca5c7647d5b3048598933efe84a3f3ca48 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Thu, 9 Jul 2026 00:07:59 -0700 Subject: [PATCH 027/225] simplify(S28): typed PendingCreateLease protocol (#4024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the async-start staleness machinery in `cmd/gc/session_lifecycle_parallel.go` into a typed `sessionpkg.PendingCreateLease` value with explicit legal transitions and a `LeaseCommitVerdict` enum, then repoints the `asyncStart*` callers to thin delegations over the lease. - `internal/session/pending_create_lease.go`: `PendingCreateLease` value type (`LeaseFromBead` / `LeaseFromInfo` constructors), the `LeaseCommitVerdict` enum, the single-sourced `stateConfirmsPendingStart` state gate, and the `SameIdentity` / `CommitVerdict` / `Confirm` transitions. - `cmd/gc/session_lifecycle_parallel.go`: `asyncStartIdentityMatches`, `asyncStartSessionStillCurrent`, and `asyncStartStaleRuntimeCleanupAllowed` become thin delegations to the lease. **Hardens the pending-create bug family** (#1542 / #2073 / #2895 / #3849) by making the previously ad-hoc boolean checks typed transitions on a single lease value — the identity match, the still-current gate, and the stale-runtime cleanup decision can no longer drift apart. **Semantics-preserved, Fable-reviewed:** an exhaustive parity grid (`TestCommitVerdict_ParityWithLegacyBooleans`) proves `CommitVerdict` reproduces the legacy `asyncStartSessionStillCurrent` / `asyncStartStaleRuntimeCleanupAllowed` booleans exactly for every (prepared, current) combination, and that the fused enum never yields KeepRuntime on the pure state gate. No persisted keys change; no store/provider I/O moves. All gates green. Spec: `engdocs/simplification/specs/S28-pending-create-lease-spec.md` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/gc/session_lifecycle_parallel.go | 95 ++----- internal/session/pending_create_lease.go | 119 +++++++++ internal/session/pending_create_lease_test.go | 241 ++++++++++++++++++ 3 files changed, 381 insertions(+), 74 deletions(-) create mode 100644 internal/session/pending_create_lease.go create mode 100644 internal/session/pending_create_lease_test.go diff --git a/cmd/gc/session_lifecycle_parallel.go b/cmd/gc/session_lifecycle_parallel.go index 4ac8234ea8..710be9f42a 100644 --- a/cmd/gc/session_lifecycle_parallel.go +++ b/cmd/gc/session_lifecycle_parallel.go @@ -1594,76 +1594,25 @@ func stopStaleAsyncStartRuntime(result startResult, sp runtime.Provider, stderr } // asyncStartSessionStillCurrent decides whether an async start result should -// commit against the current bead. Identity is established by instance_token: -// when the prepared and current tokens both exist and match, the bead is the -// same session we spawned for, even if the generation has been bumped by a -// concurrent reconciler phase (which is normal when a wave runs long enough -// for other phases to write metadata between enqueue and result completion). -// -// Rejecting on generation drift alone caused stuck-creating zombies: the -// process spawned successfully, but the result was discarded as "stale", so -// pending_create_claim never cleared and the session never advanced past -// state=creating. Falling back to generation only when the token is absent -// preserves the prior behavior for callers that pre-date instance_token. +// commit against the current bead. The decision is the typed +// sessionpkg.PendingCreateLease commit gate: instance_token is authoritative +// for identity (generation drift with a matching token still commits, the +// #1542 fix), a bead already in a live state commits regardless of the claim, +// and a claim cleared from under us discards. See PendingCreateLease.CommitVerdict. func asyncStartSessionStillCurrent(prepared, current beads.Bead) bool { - if strings.TrimSpace(current.Status) == "closed" { - return false - } - if !asyncStartIdentityMatches(prepared, current) { - return false - } - currentState := sessionpkg.State(strings.TrimSpace(current.Metadata["state"])) - // If the bead has progressed to a live state (active or awake), the spawn - // already succeeded and another phase (typically ensureRunning via attach) - // has cleared pending_create_claim. The async result still carries useful - // metadata (creation_complete_at, runtime_epoch, etc.) — commit it instead - // of discarding as "stale", which leaves the bead missing fields the rest - // of the system relies on. - if currentState == sessionpkg.StateAwake || currentState == sessionpkg.StateActive { - return true - } - // For sessions still mid-flight (creating/asleep/drained/empty), reject if - // pending_create_claim was cleared from under us — that means a different - // reconciler phase already rolled the create back, and our result would - // stomp on its decision. - if shouldRollbackPendingCreate(&prepared) && !shouldRollbackPendingCreate(¤t) { - return false - } - return confirmPendingStart(string(currentState)) + return sessionpkg.LeaseFromBead(prepared).CommitVerdict(sessionpkg.LeaseFromBead(current)) == sessionpkg.LeaseCommit } func asyncStartStaleRuntimeCleanupAllowed(prepared, current beads.Bead) bool { - if strings.TrimSpace(current.Status) == "closed" { - return true - } - if !asyncStartIdentityMatches(prepared, current) { - return true - } - currentState := sessionpkg.State(strings.TrimSpace(current.Metadata["state"])) - if shouldRollbackPendingCreate(&prepared) && !shouldRollbackPendingCreate(¤t) { - return currentState != sessionpkg.StateAwake && currentState != sessionpkg.StateActive - } - return !confirmPendingStart(string(currentState)) && - currentState != sessionpkg.StateAwake && - currentState != sessionpkg.StateActive + return sessionpkg.LeaseFromBead(prepared).CommitVerdict(sessionpkg.LeaseFromBead(current)) == sessionpkg.LeaseDiscardStopRuntime } // asyncStartIdentityMatches reports whether prepared and current describe the -// same session bead. instance_token is authoritative when both sides have one; -// only fall back to generation when the prepared bead has no token (legacy -// pre-instance_token snapshots). Generation drift with a matching token is a -// normal consequence of concurrent reconciler phases and must not invalidate -// an in-flight start result. +// same session bead. It delegates to the typed lease identity fence: +// instance_token is authoritative when the prepared side has one; generation +// is only the legacy fallback. func asyncStartIdentityMatches(prepared, current beads.Bead) bool { - preparedToken := strings.TrimSpace(prepared.Metadata["instance_token"]) - if preparedToken != "" { - return strings.TrimSpace(current.Metadata["instance_token"]) == preparedToken - } - preparedGeneration := strings.TrimSpace(prepared.Metadata["generation"]) - if preparedGeneration == "" { - return true - } - return strings.TrimSpace(current.Metadata["generation"]) == preparedGeneration + return sessionpkg.LeaseFromBead(prepared).SameIdentity(sessionpkg.LeaseFromBead(current)) } func clonePreparedStartForAsync(item preparedStart) preparedStart { @@ -1895,19 +1844,17 @@ func commitStartResult( return commitStartResultTraced(result, sessFront, clk, rec, wave, stdout, stderr, nil) } -// confirmPendingStart reports whether a session in the given metadata -// state should be transitioned to "active" after a successful runtime -// spawn. Empty, "start-pending", "creating", "asleep", and "drained" all indicate the -// session was pending a spawn; "awake" is treated by the reconciler as -// equivalent to "active" and is intentionally NOT restamped (a no-op -// metadata write on every spawn). Any other state ("draining", -// "archived", "quarantined", ...) is left alone. +// confirmPendingStart reports whether a session in the given metadata state +// should be transitioned to "active" after a successful runtime spawn. It is a +// thin string adapter over the single home for that frozen pending-start state +// set, sessionpkg.StateConfirmsPendingStart: it trims and types the raw +// metadata value, then delegates. Empty, "start-pending", "creating", +// "asleep", and "drained" all indicate the session was pending a spawn; "awake" +// is treated by the reconciler as equivalent to "active" and is intentionally +// NOT restamped (a no-op metadata write on every spawn). Any other state +// ("draining", "archived", "quarantined", ...) is left alone. func confirmPendingStart(currentState string) bool { - switch sessionpkg.State(strings.TrimSpace(currentState)) { - case "", sessionpkg.StateStartPending, sessionpkg.StateCreating, sessionpkg.StateAsleep, sessionpkg.State("drained"): - return true - } - return false + return sessionpkg.StateConfirmsPendingStart(sessionpkg.State(strings.TrimSpace(currentState))) } func commitStartResultTraced( diff --git a/internal/session/pending_create_lease.go b/internal/session/pending_create_lease.go new file mode 100644 index 0000000000..efaf33ff96 --- /dev/null +++ b/internal/session/pending_create_lease.go @@ -0,0 +1,119 @@ +package session + +import ( + "strings" + + "github.com/gastownhall/gascity/internal/beads" +) + +// PendingCreateLease is the typed projection of the optimistic-concurrency +// tuple a session bead carries around a create/start attempt. It is a pure +// value: constructed from a session bead, never holding a store. +// All persisted keys are unchanged on disk; this type only centralizes the +// reads and the transition decisions that were previously scattered across +// the async-start staleness helpers in cmd/gc. +type PendingCreateLease struct { + Closed bool // bead Status == "closed" (trimmed compare) + + // Identity fence. InstanceToken is authoritative when non-empty; + // Generation is the legacy fallback, compared as a trimmed string and + // never parsed (preserves the pre-refactor semantics exactly). + InstanceToken string // strings.TrimSpace(metadata["instance_token"]) + Generation string // strings.TrimSpace(metadata["generation"]) + + // Claim is the boolean the protocol keys on. + Claim bool // strings.TrimSpace(metadata["pending_create_claim"]) == "true" + + // State is the trimmed typed state every gate uses. + State State +} + +// LeaseFromBead projects the pending-create tuple off a raw session bead. +func LeaseFromBead(b beads.Bead) PendingCreateLease { + return PendingCreateLease{ + Closed: strings.TrimSpace(b.Status) == "closed", + InstanceToken: strings.TrimSpace(b.Metadata["instance_token"]), + Generation: strings.TrimSpace(b.Metadata["generation"]), + Claim: strings.TrimSpace(b.Metadata["pending_create_claim"]) == "true", + State: State(strings.TrimSpace(b.Metadata["state"])), + } +} + +// LeaseCommitVerdict is what the async-start commit gate returns when an +// in-flight start result meets the current bead. The two mutually-exclusive +// boolean helpers it replaces (asyncStartSessionStillCurrent / +// asyncStartStaleRuntimeCleanupAllowed) fuse into this two-outcome enum. +type LeaseCommitVerdict int + +const ( + // LeaseCommit means the result is still current — commit it against the + // current bead. + LeaseCommit LeaseCommitVerdict = iota + // LeaseDiscardStopRuntime means the result is stale — discard it and (subject + // to the separate runningSessionMatchesPendingCreate runtime probe) stop + // the spawned runtime. + LeaseDiscardStopRuntime +) + +// StateConfirmsPendingStart reports whether a session in the given state +// should transition to "active" after a successful runtime spawn. Empty, +// "start-pending", "creating", "asleep", and "drained" all indicate the +// session was pending a spawn; "awake" is treated as equivalent to "active" +// and intentionally not restamped; every other state is left alone. This is +// the single home for that frozen pending-start state set: cmd/gc's +// confirmPendingStart is a thin string adapter that delegates here. +func StateConfirmsPendingStart(s State) bool { + switch s { + case "", StateStartPending, StateCreating, StateAsleep, StateDrained: + return true + } + return false +} + +// SameIdentity reports whether the receiver (the prepared snapshot taken at +// enqueue) and current describe the same session bead. instance_token is +// authoritative when the prepared side has one; only fall back to generation +// when the prepared bead has no token (legacy pre-instance_token snapshots). +// Generation drift with a matching token is a normal consequence of +// concurrent reconciler phases and must not invalidate an in-flight start +// result (#1542). +func (l PendingCreateLease) SameIdentity(current PendingCreateLease) bool { + if l.InstanceToken != "" { + return current.InstanceToken == l.InstanceToken + } + if l.Generation == "" { + return true + } + return current.Generation == l.Generation +} + +// CommitVerdict decides whether an async start result should commit against +// current. The receiver is the prepared snapshot; current is a fresh read. +// This fuses asyncStartSessionStillCurrent (verdict == LeaseCommit) and +// asyncStartStaleRuntimeCleanupAllowed (verdict == LeaseDiscardStopRuntime). +func (l PendingCreateLease) CommitVerdict(current PendingCreateLease) LeaseCommitVerdict { + if current.Closed { + return LeaseDiscardStopRuntime + } + if !l.SameIdentity(current) { + return LeaseDiscardStopRuntime + } + // If the bead has progressed to a live state (active or awake), the spawn + // already succeeded and another phase cleared pending_create_claim. The + // async result still carries useful metadata — commit it rather than + // discarding as stale. This row fires before the claim-cleared row below, + // and that order is load-bearing (#1542). + if current.State == StateAwake || current.State == StateActive { + return LeaseCommit + } + // For sessions still mid-flight, reject if pending_create_claim was + // cleared from under us — a different reconciler phase already rolled the + // create back and committing would stomp its decision (#2073). + if l.Claim && !current.Claim { + return LeaseDiscardStopRuntime + } + if StateConfirmsPendingStart(current.State) { + return LeaseCommit + } + return LeaseDiscardStopRuntime +} diff --git a/internal/session/pending_create_lease_test.go b/internal/session/pending_create_lease_test.go new file mode 100644 index 0000000000..4778006fbb --- /dev/null +++ b/internal/session/pending_create_lease_test.go @@ -0,0 +1,241 @@ +package session + +import ( + "strings" + "testing" + "time" + + "github.com/gastownhall/gascity/internal/beads" +) + +// bead is a small helper to build a session bead with the metadata keys the +// lease reads. +func leaseBead(status string, meta map[string]string) beads.Bead { + return beads.Bead{ + ID: "gcs-1", + Status: status, + Metadata: meta, + CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } +} + +func TestStateConfirmsPendingStart(t *testing.T) { + // The frozen pending-start state set: "", start-pending, creating, asleep, + // drained confirm; everything else does not. + confirm := map[State]bool{ + "": true, + StateStartPending: true, + StateCreating: true, + StateAsleep: true, + StateDrained: true, + StateAwake: false, + StateActive: false, + StateDraining: false, + StateArchived: false, + StateQuarantined: false, + StateFailedCreate: false, + StateSuspended: false, + State("garbage-state"): false, + } + for st, want := range confirm { + if got := StateConfirmsPendingStart(st); got != want { + t.Errorf("StateConfirmsPendingStart(%q) = %v, want %v", st, got, want) + } + } +} + +func TestSameIdentity(t *testing.T) { + tests := []struct { + name string + preparedToken string + preparedGen string + currentToken string + currentGen string + want bool + }{ + {"vacuous true: prepared has neither", "", "", "anything", "anything", true}, + {"vacuous true: prepared has neither, current empty", "", "", "", "", true}, + {"token match", "tok-a", "", "tok-a", "9", true}, + {"token match despite generation drift", "tok-a", "1", "tok-a", "99", true}, + {"token mismatch", "tok-a", "", "tok-b", "", false}, + {"token authoritative: current missing token", "tok-a", "", "", "1", false}, + {"generation fallback match (no prepared token)", "", "5", "", "5", true}, + {"generation fallback mismatch", "", "5", "", "6", false}, + {"generation fallback: current missing gen", "", "5", "", "", false}, + {"whitespace-padded token match", " tok-a ", "", "tok-a", "", true}, + {"whitespace-padded gen match", "", " 5 ", "", "5", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prepared := LeaseFromBead(leaseBead("open", map[string]string{ + "instance_token": tt.preparedToken, + "generation": tt.preparedGen, + })) + current := LeaseFromBead(leaseBead("open", map[string]string{ + "instance_token": tt.currentToken, + "generation": tt.currentGen, + })) + if got := prepared.SameIdentity(current); got != tt.want { + t.Errorf("SameIdentity = %v, want %v", got, tt.want) + } + }) + } +} + +// oldStillCurrent and oldCleanupAllowed reproduce the legacy boolean helpers +// verbatim so the parity of CommitVerdict is proven against the pre-refactor +// semantics. +func oldIdentityMatches(prepared, current beads.Bead) bool { + preparedToken := trimSpace(prepared.Metadata["instance_token"]) + if preparedToken != "" { + return trimSpace(current.Metadata["instance_token"]) == preparedToken + } + preparedGeneration := trimSpace(prepared.Metadata["generation"]) + if preparedGeneration == "" { + return true + } + return trimSpace(current.Metadata["generation"]) == preparedGeneration +} + +func oldClaim(b beads.Bead) bool { + return trimSpace(b.Metadata["pending_create_claim"]) == "true" +} + +func oldStillCurrent(prepared, current beads.Bead) bool { + if trimSpace(current.Status) == "closed" { + return false + } + if !oldIdentityMatches(prepared, current) { + return false + } + currentState := State(trimSpace(current.Metadata["state"])) + if currentState == StateAwake || currentState == StateActive { + return true + } + if oldClaim(prepared) && !oldClaim(current) { + return false + } + return oldConfirm(string(currentState)) +} + +func oldCleanupAllowed(prepared, current beads.Bead) bool { + if trimSpace(current.Status) == "closed" { + return true + } + if !oldIdentityMatches(prepared, current) { + return true + } + currentState := State(trimSpace(current.Metadata["state"])) + if oldClaim(prepared) && !oldClaim(current) { + return currentState != StateAwake && currentState != StateActive + } + return !oldConfirm(string(currentState)) && + currentState != StateAwake && + currentState != StateActive +} + +func oldConfirm(currentState string) bool { + switch State(trimSpace(currentState)) { + case "", StateStartPending, StateCreating, StateAsleep, State("drained"): + return true + } + return false +} + +func TestCommitVerdict_ParityWithLegacyBooleans(t *testing.T) { + // This grid is exhaustive over the token identity dimension. The generation + // fallback branch of SameIdentity (empty instance_token + non-empty + // generation) is delegated to TestSameIdentity and the "#1542 generation + // drift" row in TestCommitVerdict_NamedInvariantRows; the identity + // projection is shared with CommitVerdict, so re-crossing generation here + // would only balloon the grid without adding coverage. + statuses := []string{"open", "closed", "in_progress"} + states := []string{"", "start-pending", "creating", "asleep", "drained", "awake", "active", "draining", "archived", "quarantined", "garbage"} + tokens := []string{"", "tok-a", "tok-b"} + claims := []string{"", "true", "yes"} + + for _, pStatus := range statuses { + for _, pState := range states { + for _, pTok := range tokens { + for _, pClaim := range claims { + for _, cStatus := range statuses { + for _, cState := range states { + for _, cTok := range tokens { + for _, cClaim := range claims { + prepared := leaseBead(pStatus, map[string]string{ + "state": pState, "instance_token": pTok, "pending_create_claim": pClaim, + }) + current := leaseBead(cStatus, map[string]string{ + "state": cState, "instance_token": cTok, "pending_create_claim": cClaim, + }) + pl := LeaseFromBead(prepared) + cl := LeaseFromBead(current) + verdict := pl.CommitVerdict(cl) + + wantCommit := oldStillCurrent(prepared, current) + wantCleanup := oldCleanupAllowed(prepared, current) + + if (verdict == LeaseCommit) != wantCommit { + t.Fatalf("CommitVerdict commit mismatch: prepared{status=%q state=%q tok=%q claim=%q} current{status=%q state=%q tok=%q claim=%q}: verdict=%v wantCommit=%v", + pStatus, pState, pTok, pClaim, cStatus, cState, cTok, cClaim, verdict, wantCommit) + } + if (verdict == LeaseDiscardStopRuntime) != wantCleanup { + t.Fatalf("CommitVerdict cleanup mismatch: prepared{status=%q state=%q tok=%q claim=%q} current{status=%q state=%q tok=%q claim=%q}: verdict=%v wantCleanup=%v", + pStatus, pState, pTok, pClaim, cStatus, cState, cTok, cClaim, verdict, wantCleanup) + } + // Exactly one verdict, and commit/cleanup are exact complements. + if wantCommit == wantCleanup { + t.Fatalf("legacy booleans not complementary at prepared{state=%q claim=%q tok=%q status=%q} current{state=%q claim=%q tok=%q status=%q}: commit=%v cleanup=%v", + pState, pClaim, pTok, pStatus, cState, cClaim, cTok, cStatus, wantCommit, wantCleanup) + } + } + } + } + } + } + } + } + } +} + +func TestCommitVerdict_NamedInvariantRows(t *testing.T) { + withState := func(state string, extra map[string]string) beads.Bead { + m := map[string]string{"instance_token": "tok-a", "state": state} + for k, v := range extra { + m[k] = v + } + return leaseBead("open", m) + } + + t.Run("#1542 commit-anyway on awake even with claim cleared", func(t *testing.T) { + prepared := LeaseFromBead(withState("creating", map[string]string{"pending_create_claim": "true"})) + current := LeaseFromBead(withState("awake", nil)) // claim cleared + if v := prepared.CommitVerdict(current); v != LeaseCommit { + t.Fatalf("want Commit, got %v", v) + } + }) + t.Run("#1542 generation drift with matching token commits", func(t *testing.T) { + prepared := LeaseFromBead(leaseBead("open", map[string]string{"instance_token": "tok-a", "generation": "1", "state": "creating"})) + current := LeaseFromBead(leaseBead("open", map[string]string{"instance_token": "tok-a", "generation": "99", "state": "creating"})) + if v := prepared.CommitVerdict(current); v != LeaseCommit { + t.Fatalf("want Commit, got %v", v) + } + }) + t.Run("#2073 claim-cleared-from-under-us discards + stops runtime", func(t *testing.T) { + prepared := LeaseFromBead(withState("creating", map[string]string{"pending_create_claim": "true"})) + current := LeaseFromBead(withState("creating", nil)) // claim cleared, not awake/active + if v := prepared.CommitVerdict(current); v != LeaseDiscardStopRuntime { + t.Fatalf("want DiscardStopRuntime, got %v", v) + } + }) + t.Run("closed current discards", func(t *testing.T) { + prepared := LeaseFromBead(withState("creating", nil)) + current := LeaseFromBead(withState("creating", nil)) + current.Closed = true + if v := prepared.CommitVerdict(current); v != LeaseDiscardStopRuntime { + t.Fatalf("want DiscardStopRuntime, got %v", v) + } + }) +} + +func trimSpace(s string) string { return strings.TrimSpace(s) } From a4cdc47afc9bd6047bad94d82870265229cc62fa Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Thu, 9 Jul 2026 02:26:03 -0700 Subject: [PATCH 028/225] feat(api): gate city reads on an opt-in signed read grant (read-auth) (#4100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds `internal/api/readauth.go`, the read-side twin of the write-auth gate (#3791 / #3792): an **opt-in, fail-closed** middleware requiring a signed, single-use, request-bound `X-GC-City-Read` grant (audience `gc-city-read`) on every GET/HEAD of the typed per-city API under `/v0/city/{cityName}`. With no `read_auth_verify_key` configured the middleware is **not installed** and reads stay open — behavior is bit-identical to today (opt-in). `read_auth_required` (or `GC_CITY_READ_REQUIRED=1`) makes a missing key a boot-time hard fail. This lets an authority-fronted deployment require an authenticated grant to read a city's beads, mail, sessions, and agent transcripts instead of trusting network position. Highlights: - Reuses `internal/citywriteauth` unchanged (a second `Verifier` with a distinct audience). Reads carry no body, so the grant binds method+path+query over an empty body and is consumed at admission; there is no CSRF or read-only front-door (a read changes no state and must work in read-only mode). SSE stream reads are gated at connect. - Refactor: extracts the shared path grammar into `cityScopedObjectPath`; `cityScopedObjectMutation` delegates to it (write path unchanged). - **Scope boundary (v1):** gates only the typed `/v0/city` reads. The supervisor-scope aggregate event feed (`/v0/events[/stream]`) and the default-on dashboard host plane (`/api/*`) remain ungated — documented in the config field so operators don't over-trust the boundary; gating them is follow-up. - No new public wire/OpenAPI surface (mux-level gate, like write-auth). Only new strings are `X-GC-City-Read` / `gc-city-read`, mirroring the existing `X-GC-City-Write` / `gc-city-write`. ## Testing - [x] `golangci-lint` (0 issues), `gofmt` clean, `go vet ./...`, `go test ./test/docsync`, `make generate` in sync, and full `internal/api` + `internal/config` + `internal/supervisor` tests all pass — run manually because the local pre-commit hook kept losing a fleet-wide golangci-lint lock. - [ ] `make check` — deferred to CI (local run blocked by the fleet golangci-lint lock; equivalent gates run manually, above). - [x] Added `internal/api/readauth_test.go` (missing-grant 401, valid grant, HEAD/method binding, audience isolation both directions, query binding, SSE admission, control-char reject, replay, wrong-city, read-only mode, resolved-verifier behavioral, and full end-to-end through `SupervisorMux.Handler()`). ## Checklist - [x] Linked an issue, or explained why one is not needed — additive, opt-in read-side complement to the #3791/#3792 write-auth gate. - [x] Added or updated tests for behavior changes - [x] Updated docs for user-facing changes (`read_auth_verify_key` / `read_auth_required` config fields; `docs/reference/config.md` + schema regenerated via `make generate`) - [x] Called out breaking changes or migration notes — none; opt-in, default behavior unchanged. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/gc/cmd_supervisor.go | 7 + cmd/gc/controller.go | 6 + cmd/gc/supervisor_dashboard.go | 11 +- cmd/gc/supervisor_dashboard_test.go | 26 +- docs/reference/config.md | 2 + docs/reference/schema/city-schema.json | 8 + docs/reference/schema/city-schema.txt | 8 + internal/api/dashboardbff/plane.go | 10 + internal/api/dashboardbff/runtailer.go | 2 +- internal/api/dashboardbff/samplers.go | 2 +- internal/api/dashboardbff/samplers_test.go | 47 ++ internal/api/loopback_transport.go | 95 ++++ internal/api/loopback_transport_test.go | 68 +++ internal/api/middleware.go | 2 +- internal/api/readauth.go | 180 ++++++ internal/api/readauth_test.go | 619 +++++++++++++++++++++ internal/api/supervisor.go | 17 + internal/api/writeauth.go | 55 +- internal/config/config.go | 38 ++ internal/supervisor/config.go | 6 + 20 files changed, 1180 insertions(+), 29 deletions(-) create mode 100644 internal/api/loopback_transport.go create mode 100644 internal/api/loopback_transport_test.go create mode 100644 internal/api/readauth.go create mode 100644 internal/api/readauth_test.go diff --git a/cmd/gc/cmd_supervisor.go b/cmd/gc/cmd_supervisor.go index 16bfb92ed4..0540fc5a56 100644 --- a/cmd/gc/cmd_supervisor.go +++ b/cmd/gc/cmd_supervisor.go @@ -1360,6 +1360,13 @@ func runSupervisor(stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "gc supervisor: write-auth: %v\n", err) //nolint:errcheck return 1 } + // Gate city reads on a signed read grant when configured. Fail closed at boot + // if read-auth is required but no key is set, so the supervisor cannot + // silently serve reads unguarded. + if err := api.InstallReadAuth(apiMux, supCfg.Supervisor.ReadAuthVerifyKey, supCfg.Supervisor.ReadAuthRequired); err != nil { + fmt.Fprintf(stderr, "gc supervisor: read-auth: %v\n", err) //nolint:errcheck + return 1 + } // Host the embedded dashboard SPA + host-side /api plane on the same // listener (same-origin), so the supervisor serves the dashboard for all diff --git a/cmd/gc/controller.go b/cmd/gc/controller.go index 10edbd0600..d921dce5d6 100644 --- a/cmd/gc/controller.go +++ b/cmd/gc/controller.go @@ -1367,6 +1367,12 @@ func runController( fmt.Fprintf(stderr, "api: write-auth: %v\n", err) //nolint:errcheck return 1 } + // Gate city reads on a signed read grant when configured. Fail closed at + // boot if read-auth is required but no key is set. + if err := api.InstallReadAuth(apiMux, cfg.API.ReadAuthVerifyKey, cfg.API.ReadAuthRequired); err != nil { + fmt.Fprintf(stderr, "api: read-auth: %v\n", err) //nolint:errcheck + return 1 + } addr := net.JoinHostPort(bind, strconv.Itoa(cfg.API.Port)) apiLis, apiErr := net.Listen("tcp", addr) if apiErr != nil { diff --git a/cmd/gc/supervisor_dashboard.go b/cmd/gc/supervisor_dashboard.go index f843fd602e..89693c42a5 100644 --- a/cmd/gc/supervisor_dashboard.go +++ b/cmd/gc/supervisor_dashboard.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "net" + "net/http" "os" "path/filepath" "strconv" @@ -64,19 +65,23 @@ func attachDashboard(mux *api.SupervisorMux, resolver api.CityResolver, readOnly if err != nil { return nil, err } - plane := dashboardbff.New(dashboardDeps(resolver, readOnly, bind, port)) + plane := dashboardbff.New(dashboardDeps(resolver, readOnly, bind, port, mux.LoopbackTransport())) mux.WithAPIPlane(plane.Handler()).WithStaticHandler(spa) return plane, nil } // dashboardDeps builds the plane's dependencies. Extracted so a regression test // can assert the wiring (notably a non-empty SupervisorBaseURL, without which -// the host-side samplers would silently ship permanently degraded). -func dashboardDeps(resolver api.CityResolver, readOnly bool, bind string, port int) dashboardbff.Deps { +// the host-side samplers would silently ship permanently degraded, and a +// non-nil SelfReadTransport, without which the samplers' loopback self-reads +// would 401 under read-auth). selfRead is the supervisor's in-process loopback +// transport so those trusted self-reads bypass the read-auth gate. +func dashboardDeps(resolver api.CityResolver, readOnly bool, bind string, port int, selfRead http.RoundTripper) dashboardbff.Deps { return dashboardbff.Deps{ Resolver: dashboardCityResolver{resolver}, ReadOnly: readOnly, SupervisorBaseURL: dashboardLoopbackBaseURL(bind, port), + SelfReadTransport: selfRead, RunCwdAllowedRoots: runCwdAllowedRootsFromEnv(), OperatorAlias: os.Getenv("DASHBOARD_OPERATOR_ALIAS"), OperatorWireAlias: os.Getenv("DASHBOARD_OPERATOR_WIRE_ALIAS"), diff --git a/cmd/gc/supervisor_dashboard_test.go b/cmd/gc/supervisor_dashboard_test.go index b52e3c50f6..169fdc4a81 100644 --- a/cmd/gc/supervisor_dashboard_test.go +++ b/cmd/gc/supervisor_dashboard_test.go @@ -1,6 +1,7 @@ package main import ( + "net/http" "testing" "github.com/gastownhall/gascity/internal/api" @@ -9,6 +10,12 @@ import ( type fakeDashResolver struct{ cities []api.CityInfo } +// stubRoundTripper is a sentinel http.RoundTripper for asserting that +// dashboardDeps stores the self-read transport it is handed. +type stubRoundTripper struct{} + +func (*stubRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { return nil, nil } + func (f fakeDashResolver) ListCities() []api.CityInfo { return f.cities } func (f fakeDashResolver) CityState(string) api.State { return nil } @@ -39,7 +46,7 @@ func TestDashboardLoopbackBaseURL(t *testing.T) { // red-team HIGH finding: attachDashboard must give the plane a non-empty // SupervisorBaseURL, or the host-side samplers ship permanently degraded. func TestDashboardDepsWiresSupervisorBaseURL(t *testing.T) { - deps := dashboardDeps(fakeDashResolver{}, false, "127.0.0.1", 8372) + deps := dashboardDeps(fakeDashResolver{}, false, "127.0.0.1", 8372, nil) if deps.SupervisorBaseURL == "" { t.Fatal("dashboardDeps left SupervisorBaseURL empty; samplers would never read /v0/.../status") } @@ -51,13 +58,28 @@ func TestDashboardDepsWiresSupervisorBaseURL(t *testing.T) { } } +// TestDashboardDepsWiresSelfReadTransport is the regression guard for the +// read-auth finding: attachDashboard must give the plane the supervisor's +// in-process loopback transport, or the host-side samplers' loopback self-reads +// of the gated /v0/city/{name}/status route would 401 once read-auth is enabled. +func TestDashboardDepsWiresSelfReadTransport(t *testing.T) { + rt := &stubRoundTripper{} + deps := dashboardDeps(fakeDashResolver{}, false, "127.0.0.1", 8372, rt) + if deps.SelfReadTransport == nil { + t.Fatal("dashboardDeps left SelfReadTransport nil; samplers' loopback reads would 401 under read-auth") + } + if deps.SelfReadTransport != http.RoundTripper(rt) { + t.Errorf("SelfReadTransport = %v, want the passed-in transport", deps.SelfReadTransport) + } +} + // TestDashboardDepsModulesCoreOnly records that core-only dashboard modules are // the intentional steady state: dashboardDeps leaves EnabledModules unset // because no first-party (gated) view module ships yet, so the omission is a // tested decision rather than an oversight. When a gated module is added, wire // its enable source in dashboardDeps and update this test. func TestDashboardDepsModulesCoreOnly(t *testing.T) { - deps := dashboardDeps(fakeDashResolver{}, false, "127.0.0.1", 8372) + deps := dashboardDeps(fakeDashResolver{}, false, "127.0.0.1", 8372, nil) if len(deps.EnabledModules) != 0 { t.Errorf("EnabledModules = %v, want empty: core-only is the intentional default; wire the enable source and update this test when a gated module ships", deps.EnabledModules) } diff --git a/docs/reference/config.md b/docs/reference/config.md index 1a0a6843e9..cb1c598af3 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -69,6 +69,8 @@ APIConfig configures the HTTP API server. | `allow_mutations` | boolean | | | AllowMutations overrides the default read-only behavior when bind is non-localhost. Set to true in containerized environments where the API must bind to 0.0.0.0 for health probes but mutations are still safe. | | `write_auth_verify_key` | string | | | WriteAuthVerifyKey, when set, requires every mutating request to an already-registered city — the per-city routes under /v0/city/{cityName} — to carry a signed write grant from a configured trusted authority. It gates all per-city writes (beads, mail, sessions, agents, and config), not only config edits. City registry creation (POST /v0/city) is not covered: a grant binds a path-resident city name, which a not-yet-created city lacks, so creation stays governed by the supervisor-registry guards. Built-in callers (the bundled gc API client and dashboard SPA) send only the CSRF header and mint no grant, so enabling this gate turns their direct city mutations away with a clear 401; such deployments front mutations through the trusted authority that mints grants instead. The value is one or more "kid:base64-ed25519-pubkey" entries, comma separated. The GC_CITY_WRITE_PUBKEY env var overrides this. Grant revocation via an epoch floor is an ops-plane control set only through the GC_CITY_WRITE_EPOCH_FLOOR env var; it has no config field. | | `write_auth_required` | boolean | | | WriteAuthRequired makes a missing or empty WriteAuthVerifyKey a startup error instead of silently disabling the gate, so a config that intends to gate writes fails closed if the key is ever dropped. The GC_CITY_WRITE_REQUIRED=1 env var has the same effect. | +| `read_auth_verify_key` | string | | | ReadAuthVerifyKey, when set, requires every read (GET/HEAD) of an already-registered city on the typed per-city API — the routes under /v0/city/{cityName} — to carry a signed read grant from a configured trusted authority. It is the read-side twin of WriteAuthVerifyKey, adding in-process, grant-based admission control to the typed city read surface (beads, mail, sessions, agent transcripts) instead of trusting network position. Scope boundary: this gate covers ONLY the typed /v0/city/{cityName} read routes. It does NOT cover other surfaces on the same listener that can also expose per-city data: the supervisor-scope aggregate event feed (/v0/events and /v0/events/stream, which multiplex every running city's events), the default-on dashboard host plane (/api/*, including its /api/city/{cityName}/* samplers, run detail, run diff, and config reads), and the supervisor-scope routes /v0/cities, /health, /v0/readiness, /v0/provider-readiness, the OpenAPI document, and the dashboard SPA shell. On a non-localhost bind, the only complete mitigation is to front the whole listener with the grant-minting authority/edge (the intended deployment), which protects every surface above. Disabling the dashboard host plane with GC_SUPERVISOR_DASHBOARD=0 is additive, not a substitute: it closes /api/* only, while the supervisor-scope event feed /v0/events and /v0/events/stream stays readable by network position until the follow-up supervisor-scope grant lands. Gating those feeds is tracked as that follow-up work. Built-in callers (the bundled gc API client and dashboard SPA) mint no grant, so enabling this gate turns their direct /v0/city reads away with a clear 401; such deployments front reads through the authority that mints grants. The value is one or more "kid:base64-ed25519-pubkey" entries, comma separated. The GC_CITY_READ_PUBKEY env var overrides this. Grant revocation via an epoch floor is an ops-plane control set only through the GC_CITY_READ_EPOCH_FLOOR env var; it has no config field. | +| `read_auth_required` | boolean | | | ReadAuthRequired makes a missing or empty ReadAuthVerifyKey a startup error instead of silently disabling the gate, so a config that intends to gate reads fails closed if the key is ever dropped. The GC_CITY_READ_REQUIRED=1 env var has the same effect. | ## Agent diff --git a/docs/reference/schema/city-schema.json b/docs/reference/schema/city-schema.json index f536435f1b..3e3f1680b4 100644 --- a/docs/reference/schema/city-schema.json +++ b/docs/reference/schema/city-schema.json @@ -46,6 +46,14 @@ "write_auth_required": { "type": "boolean", "description": "WriteAuthRequired makes a missing or empty WriteAuthVerifyKey a startup\nerror instead of silently disabling the gate, so a config that intends to\ngate writes fails closed if the key is ever dropped. The\nGC_CITY_WRITE_REQUIRED=1 env var has the same effect." + }, + "read_auth_verify_key": { + "type": "string", + "description": "ReadAuthVerifyKey, when set, requires every read (GET/HEAD) of an\nalready-registered city on the typed per-city API — the routes under\n/v0/city/{cityName} — to carry a signed read grant from a configured\ntrusted authority. It is the read-side twin of WriteAuthVerifyKey, adding\nin-process, grant-based admission control to the typed city read surface\n(beads, mail, sessions, agent transcripts) instead of trusting network\nposition.\n\nScope boundary: this gate covers ONLY the typed /v0/city/{cityName} read\nroutes. It does NOT cover other surfaces on the same listener that can also\nexpose per-city data: the supervisor-scope aggregate event feed (/v0/events\nand /v0/events/stream, which multiplex every running city's events), the\ndefault-on dashboard host plane (/api/*, including its /api/city/{cityName}/*\nsamplers, run detail, run diff, and config reads), and the supervisor-scope\nroutes /v0/cities, /health, /v0/readiness, /v0/provider-readiness, the\nOpenAPI document, and the dashboard SPA shell. On a non-localhost bind, the\nonly complete mitigation is to front the whole listener with the\ngrant-minting authority/edge (the intended deployment), which protects\nevery surface above. Disabling the dashboard host plane with\nGC_SUPERVISOR_DASHBOARD=0 is additive, not a substitute: it closes /api/*\nonly, while the supervisor-scope event feed /v0/events and\n/v0/events/stream stays readable by network position until the follow-up\nsupervisor-scope grant lands. Gating those feeds is tracked as that\nfollow-up work.\n\nBuilt-in callers (the bundled gc API client and dashboard SPA) mint no\ngrant, so enabling this gate turns their direct /v0/city reads away with a\nclear 401; such deployments front reads through the authority that mints\ngrants. The value is one or more \"kid:base64-ed25519-pubkey\" entries, comma\nseparated. The GC_CITY_READ_PUBKEY env var overrides this. Grant revocation\nvia an epoch floor is an ops-plane control set only through the\nGC_CITY_READ_EPOCH_FLOOR env var; it has no config field." + }, + "read_auth_required": { + "type": "boolean", + "description": "ReadAuthRequired makes a missing or empty ReadAuthVerifyKey a startup error\ninstead of silently disabling the gate, so a config that intends to gate\nreads fails closed if the key is ever dropped. The GC_CITY_READ_REQUIRED=1\nenv var has the same effect." } }, "additionalProperties": false, diff --git a/docs/reference/schema/city-schema.txt b/docs/reference/schema/city-schema.txt index f536435f1b..3e3f1680b4 100644 --- a/docs/reference/schema/city-schema.txt +++ b/docs/reference/schema/city-schema.txt @@ -46,6 +46,14 @@ "write_auth_required": { "type": "boolean", "description": "WriteAuthRequired makes a missing or empty WriteAuthVerifyKey a startup\nerror instead of silently disabling the gate, so a config that intends to\ngate writes fails closed if the key is ever dropped. The\nGC_CITY_WRITE_REQUIRED=1 env var has the same effect." + }, + "read_auth_verify_key": { + "type": "string", + "description": "ReadAuthVerifyKey, when set, requires every read (GET/HEAD) of an\nalready-registered city on the typed per-city API — the routes under\n/v0/city/{cityName} — to carry a signed read grant from a configured\ntrusted authority. It is the read-side twin of WriteAuthVerifyKey, adding\nin-process, grant-based admission control to the typed city read surface\n(beads, mail, sessions, agent transcripts) instead of trusting network\nposition.\n\nScope boundary: this gate covers ONLY the typed /v0/city/{cityName} read\nroutes. It does NOT cover other surfaces on the same listener that can also\nexpose per-city data: the supervisor-scope aggregate event feed (/v0/events\nand /v0/events/stream, which multiplex every running city's events), the\ndefault-on dashboard host plane (/api/*, including its /api/city/{cityName}/*\nsamplers, run detail, run diff, and config reads), and the supervisor-scope\nroutes /v0/cities, /health, /v0/readiness, /v0/provider-readiness, the\nOpenAPI document, and the dashboard SPA shell. On a non-localhost bind, the\nonly complete mitigation is to front the whole listener with the\ngrant-minting authority/edge (the intended deployment), which protects\nevery surface above. Disabling the dashboard host plane with\nGC_SUPERVISOR_DASHBOARD=0 is additive, not a substitute: it closes /api/*\nonly, while the supervisor-scope event feed /v0/events and\n/v0/events/stream stays readable by network position until the follow-up\nsupervisor-scope grant lands. Gating those feeds is tracked as that\nfollow-up work.\n\nBuilt-in callers (the bundled gc API client and dashboard SPA) mint no\ngrant, so enabling this gate turns their direct /v0/city reads away with a\nclear 401; such deployments front reads through the authority that mints\ngrants. The value is one or more \"kid:base64-ed25519-pubkey\" entries, comma\nseparated. The GC_CITY_READ_PUBKEY env var overrides this. Grant revocation\nvia an epoch floor is an ops-plane control set only through the\nGC_CITY_READ_EPOCH_FLOOR env var; it has no config field." + }, + "read_auth_required": { + "type": "boolean", + "description": "ReadAuthRequired makes a missing or empty ReadAuthVerifyKey a startup error\ninstead of silently disabling the gate, so a config that intends to gate\nreads fails closed if the key is ever dropped. The GC_CITY_READ_REQUIRED=1\nenv var has the same effect." } }, "additionalProperties": false, diff --git a/internal/api/dashboardbff/plane.go b/internal/api/dashboardbff/plane.go index 7995299f1b..48fb34f410 100644 --- a/internal/api/dashboardbff/plane.go +++ b/internal/api/dashboardbff/plane.go @@ -57,6 +57,16 @@ type Deps struct { // API (e.g. "http://127.0.0.1:8372"), used by the host-side samplers to // read /v0/city/{name}/status. Empty disables the samplers' status reads. SupervisorBaseURL string + // SelfReadTransport, when set, is the http.RoundTripper the host-side + // samplers and run tailers use for their loopback reads of the supervisor's + // own /v0/city/{name}/... routes. The supervisor supplies an in-process + // transport (SupervisorMux.LoopbackTransport) that dispatches these trusted + // self-reads against its un-gated inner handler, so they keep working when + // read-auth is enabled — the /api/* plane is outside the read-auth gate by + // design, but its data source /v0/city/{name}/status is gated, so a networked + // self-read would 401. Nil falls back to the default network transport, which + // the package tests rely on. + SelfReadTransport http.RoundTripper // Runtime-config projection inputs. Neutral defaults are supplied by the // caller from gc config/env (ZERO hardcoded roles). diff --git a/internal/api/dashboardbff/runtailer.go b/internal/api/dashboardbff/runtailer.go index cafbce53f9..9571695d89 100644 --- a/internal/api/dashboardbff/runtailer.go +++ b/internal/api/dashboardbff/runtailer.go @@ -63,7 +63,7 @@ type runTailerManager struct { func newRunTailerManager(deps Deps) *runTailerManager { return &runTailerManager{ deps: deps, - httpc: &http.Client{Timeout: runSessionsFetchTimeout}, + httpc: &http.Client{Timeout: runSessionsFetchTimeout, Transport: deps.SelfReadTransport}, cities: make(map[string]*cityRunTailer), sessionsCache: newSingleFlightCache[string, cachedSessions](), formulaCache: newSingleFlightCache[formulaCacheKey, cachedFormulaDetail](), diff --git a/internal/api/dashboardbff/samplers.go b/internal/api/dashboardbff/samplers.go index 421afe04a5..b192090e2a 100644 --- a/internal/api/dashboardbff/samplers.go +++ b/internal/api/dashboardbff/samplers.go @@ -112,7 +112,7 @@ func newSamplerManager(deps Deps, exec *execRunner) *samplerManager { return &samplerManager{ deps: deps, exec: exec, - httpc: &http.Client{Timeout: statusFetchTimeout}, + httpc: &http.Client{Timeout: statusFetchTimeout, Transport: deps.SelfReadTransport}, cities: make(map[string]*citySampler), } } diff --git a/internal/api/dashboardbff/samplers_test.go b/internal/api/dashboardbff/samplers_test.go index e85eb08b8e..790b0648a6 100644 --- a/internal/api/dashboardbff/samplers_test.go +++ b/internal/api/dashboardbff/samplers_test.go @@ -2,12 +2,59 @@ package dashboardbff import ( "context" + "io" "net/http" "net/http/httptest" + "strings" "testing" "time" ) +// recordingRoundTripper is a fake in-process transport standing in for the +// supervisor's LoopbackTransport: it records the request path and returns a +// canned response without touching the network, so a test can prove the +// samplers dispatch loopback reads through Deps.SelfReadTransport. +type recordingRoundTripper struct { + gotPath string + status int + body string +} + +func (rt *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + rt.gotPath = req.URL.Path + code := rt.status + if code == 0 { + code = http.StatusOK + } + return &http.Response{ + StatusCode: code, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(rt.body)), + Request: req, + }, nil +} + +// TestSamplersUseSelfReadTransport is the regression test for the read-auth +// finding at the sampler layer: fetchStatus must dispatch its loopback status +// read through Deps.SelfReadTransport (the supervisor's in-process transport), +// not the network. The base URL is deliberately unroutable, so a networked read +// would fail; the canned status body proves the transport was used. +func TestSamplersUseSelfReadTransport(t *testing.T) { + rt := &recordingRoundTripper{status: http.StatusOK, body: `{"store_health":{"size_bytes":42}}`} + m := newSamplerManager(Deps{SupervisorBaseURL: "http://supervisor.invalid", SelfReadTransport: rt}, newExecRunner()) + + raw, err := m.fetchStatus(context.Background(), "alpha") + if err != nil { + t.Fatalf("fetchStatus via self-read transport: %v", err) + } + if rt.gotPath != "/v0/city/alpha/status" { + t.Fatalf("transport saw path %q, want /v0/city/alpha/status", rt.gotPath) + } + if !strings.Contains(string(raw), "size_bytes") { + t.Fatalf("fetchStatus body = %q, want the transport's canned status", raw) + } +} + // statusServer returns an httptest server that serves a fixed supervisor status // body at /v0/city/{name}/status, so refresh()'s fetchStatus succeeds. func statusServer(t *testing.T, body string) *httptest.Server { diff --git a/internal/api/loopback_transport.go b/internal/api/loopback_transport.go new file mode 100644 index 0000000000..6df96ab497 --- /dev/null +++ b/internal/api/loopback_transport.go @@ -0,0 +1,95 @@ +package api + +import ( + "bytes" + "io" + "net/http" +) + +// LoopbackTransport returns an http.RoundTripper that serves a request against +// the supervisor's own un-gated inner handler in-process, without a network +// hop. It exists for the supervisor's server-side self-reads — the dashboard +// /api plane's status and run-view samplers — which must read the supervisor's +// own typed /v0/city/{name}/... routes over loopback to build the /api/* +// responses. +// +// Those self-reads intentionally bypass the read-auth gate. The gate exists to +// stop city reads from network position; a self-read is the supervisor reading +// its own state to serve the /api/* plane, which is itself documented as +// outside the read-auth gate (an authority fronting the whole listener protects +// /api/* — and the self-read is behind that same boundary). Routing the +// self-read back through the network listener would instead hand it a read-auth +// 401 whenever read-auth is enabled, silently degrading the dashboard health and +// run views. Dispatching against the inner handler keeps the self-read on the +// same typed handlers without the edge middleware (auth, host allow-listing, +// CORS) that only applies to external callers. +func (sm *SupervisorMux) LoopbackTransport() http.RoundTripper { + return loopbackTransport{h: http.HandlerFunc(sm.ServeHTTP)} +} + +// loopbackTransport dispatches a request against an in-process handler instead +// of dialing the network. It is the mechanism behind SupervisorMux.LoopbackTransport. +type loopbackTransport struct{ h http.Handler } + +// RoundTrip serves req against the wrapped handler and returns the recorded +// response. It never returns a transport error: the wrapped handler always +// produces a response, and a handler panic is contained as a 500 (the inner +// handler runs without the outer recovery middleware, so containing it here +// keeps a self-read from crashing the caller's goroutine). +func (t loopbackTransport) RoundTrip(req *http.Request) (*http.Response, error) { + rec := &loopbackRecorder{header: make(http.Header)} + func() { + defer func() { + if r := recover(); r != nil && !rec.wroteHeader { + rec.status = http.StatusInternalServerError + } + }() + t.h.ServeHTTP(rec, req) + }() + status := rec.status + if status == 0 { + status = http.StatusOK + } + return &http.Response{ + StatusCode: status, + Status: http.StatusText(status), + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: rec.header, + Body: io.NopCloser(bytes.NewReader(rec.body.Bytes())), + Request: req, + }, nil +} + +// loopbackRecorder is a minimal http.ResponseWriter that buffers the status, +// headers, and body an in-process handler writes, so loopbackTransport can +// project them onto an *http.Response. It captures only what the self-read +// callers consume (status code and body); it deliberately does not reinvent +// httptest.ResponseRecorder's full machinery. +type loopbackRecorder struct { + header http.Header + body bytes.Buffer + status int + wroteHeader bool +} + +func (r *loopbackRecorder) Header() http.Header { return r.header } + +func (r *loopbackRecorder) WriteHeader(status int) { + if !r.wroteHeader { + r.status = status + r.wroteHeader = true + } +} + +func (r *loopbackRecorder) Write(b []byte) (int, error) { + if !r.wroteHeader { + r.WriteHeader(http.StatusOK) + } + return r.body.Write(b) +} + +// Flush is a no-op that lets handlers which probe for http.Flusher (streaming +// writers) succeed; the buffered body is already complete when RoundTrip reads it. +func (r *loopbackRecorder) Flush() {} diff --git a/internal/api/loopback_transport_test.go b/internal/api/loopback_transport_test.go new file mode 100644 index 0000000000..7d319bdb56 --- /dev/null +++ b/internal/api/loopback_transport_test.go @@ -0,0 +1,68 @@ +package api + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// TestSupervisorMuxLoopbackTransportBypassesReadAuth is the regression test for +// the read-auth review finding: the dashboard /api plane's loopback self-reads +// of the gated /v0/city/{name}/status route must keep working when read-auth is +// enabled. LoopbackTransport dispatches the trusted self-read against the +// un-gated inner handler, so it clears the gate and serves the status; the same +// read over the network listener without a grant is still rejected 401, so the +// bypass is scoped to in-process self-reads and does not weaken the gate. +func TestSupervisorMuxLoopbackTransportBypassesReadAuth(t *testing.T) { + pub, _ := mustKeypair(t) + sm := newTestSupervisorMux(t, map[string]*fakeState{"test-city": newFakeState(t)}) + sm.WithAnyHostAllowed().WithReadAuth(newTestReadVerifier(t, pub, time.Now())) + + const target = "/v0/city/test-city/status" + + // In-process loopback transport: the trusted self-read bypasses the gate and + // reaches the status handler, even though no read grant is presented. + client := &http.Client{Transport: sm.LoopbackTransport()} + resp, err := client.Get("http://supervisor.local" + target) + if err != nil { + t.Fatalf("loopback get: %v", err) + } + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("loopback self-read under read-auth: status=%d want 200 (gate must be bypassed); body=%s", resp.StatusCode, body) + } + + // The same read over the network listener without a grant is still gated, + // proving the bypass is confined to the in-process transport. + srv := httptest.NewServer(sm.Handler()) + defer srv.Close() + netResp, err := http.Get(srv.URL + target) + if err != nil { + t.Fatalf("network get: %v", err) + } + defer func() { _ = netResp.Body.Close() }() + if netResp.StatusCode != http.StatusUnauthorized { + t.Fatalf("network read without grant: status=%d want 401 (gate must stay active)", netResp.StatusCode) + } +} + +// TestSupervisorMuxLoopbackTransportContainsPanics proves a handler panic on the +// in-process path is contained as a 500 rather than crashing the self-read +// caller's goroutine — the inner handler runs without the outer recovery +// middleware, so loopbackTransport must recover itself. +func TestSupervisorMuxLoopbackTransportContainsPanics(t *testing.T) { + panicking := loopbackTransport{h: http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + panic("boom") + })} + req := httptest.NewRequest(http.MethodGet, "/v0/city/acme/status", nil) + resp, err := panicking.RoundTrip(req) + if err != nil { + t.Fatalf("RoundTrip returned a transport error instead of containing the panic: %v", err) + } + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("contained panic status=%d want 500", resp.StatusCode) + } +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 97b20e016c..cecbaa269e 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -145,7 +145,7 @@ func withCORSAllowing(extra []string, next http.Handler) http.Handler { if originAllowed(origin, extra) { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Last-Event-ID, X-GC-Request, X-GC-City-Write") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Last-Event-ID, X-GC-Request, X-GC-City-Write, X-GC-City-Read") w.Header().Set("Access-Control-Expose-Headers", "X-GC-Index, X-GC-Request-Id, Retry-After") } if r.Method == http.MethodOptions { diff --git a/internal/api/readauth.go b/internal/api/readauth.go new file mode 100644 index 0000000000..6106b101fc --- /dev/null +++ b/internal/api/readauth.go @@ -0,0 +1,180 @@ +package api + +import ( + "errors" + "fmt" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/gastownhall/gascity/internal/citywriteauth" +) + +// Read-auth gates per-city reads on a signed, single-use, request-bound grant +// when a verifying key is configured. It is the read-side twin of write-auth: it +// covers every GET/HEAD to an already-registered city on the typed per-city API +// (the routes under /v0/city/{cityName}), so a deployment can require an +// authenticated grant to read a city's beads, mail, sessions, and agent +// transcripts rather than trusting network position. It is opt-in hardening: with +// no key configured the middleware is not installed and reads follow the prior +// behavior; with a key configured it is fail-closed — every city-scoped read must +// present a valid grant minted by the configured trusted authority. +// +// Scope boundary: this covers ONLY the typed /v0/city/{cityName} read routes. It +// deliberately does NOT gate the supervisor-scope aggregate event feed +// (/v0/events, /v0/events/stream — which multiplex per-city events across all +// running cities) nor the default-on dashboard host plane (/api/*, including its +// /api/city/{cityName}/* per-city samplers, run detail/diff, and config reads), +// which expose per-city data on the same listener. Those surfaces are covered by +// the grant-minting authority/edge when it fronts the whole listener; gating them +// in-process is follow-up work under the supervisor-scope grant. See the +// ReadAuthVerifyKey config doc for the operator guidance. +// +// The bundled first-party callers (the gc API client and dashboard SPA) mint no +// grant, so enabling the gate turns their direct /v0/city reads away with a clear +// 401; an authority-fronted deployment supplies grants out of band rather than +// minting them in this process. +const ( + readAuthHeader = "X-GC-City-Read" + readAuthAudience = "gc-city-read" + + // readAuthMaxTTL and readAuthSkew bound grant lifetime and clock drift. + // Kept as independent consts from the write-auth pair so the tiers can + // diverge later; the minter and verifier share a pod, so drift is small. + readAuthMaxTTL = 2 * time.Minute + readAuthSkew = 30 * time.Second +) + +// readAuthMiddleware enforces a valid X-GC-City-Read grant on every city-scoped +// read (GET/HEAD). Mutations and non-city-scoped routes pass through untouched. +// +// Unlike the write gate it deliberately has no CSRF or read-only front-door +// checks — a read changes no state (so CSRF is moot and the browser same-origin +// policy already blocks a cross-site attacker from reading the response) and +// reads must keep working in read-only mode — and it buffers no request body, +// because a GET/HEAD carries none. The grant is therefore bound to +// method+path+query over an empty body and consumed exactly at admission; there +// are no cheap pre-checks between token presence and verification, so the +// don't-burn-the-jti ordering the write path needs does not apply here. The +// single-use grant is consumed even when the downstream handler later 404s or +// 500s, which is harmless. +// +// For streaming reads (SSE feeds under a city) the gate runs at connect only and +// wraps nothing around the ResponseWriter, so flushing/streaming pass through +// untouched. Each reconnect (including Last-Event-ID resumes) is a fresh request +// needing a fresh grant. +func readAuthMiddleware(v *citywriteauth.Verifier, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + next.ServeHTTP(w, r) + return + } + city, ok := cityScopedObjectPath(r.URL.Path) + if !ok { + next.ServeHTTP(w, r) + return + } + + // Fail closed on control characters in a gated path: the digest preimage + // is newline-delimited and r.URL.Path can carry a decoded \n/\r/NUL from + // %0A/%0D/%00, so reject before digesting. Such paths also fail exact-match + // routing, so this rejects nothing a handler would otherwise serve. + if strings.ContainsAny(r.URL.Path, "\n\r\x00") { + problemReadAuthBadPath.writeTo(w) + return + } + + token := r.Header.Get(readAuthHeader) + if token == "" { + problemReadAuthMissingGrant.writeTo(w) + return + } + + expect := citywriteauth.Expect{ + City: city, + ReqDigest: citywriteauth.ReqDigest(r.Method, r.URL.Path, r.URL.RawQuery, nil), + } + if _, err := v.Verify(token, expect); err != nil { + // Deliberately generic to the client (no verification oracle); the + // specific reason is for server-side audit, not the response. + problemReadAuthRejected.writeTo(w) + return + } + next.ServeHTTP(w, r) + }) +} + +// Pre-serialized RFC 9457 problem responses for the read-auth gate. Like the +// other mux-level problemBody values, pre-serialization keeps json.Marshal off +// the rejection path (Principle 8) and matches the typed-wire convention instead +// of hand-encoding a map[string]any. +var ( + problemReadAuthMissingGrant = problemBody{ + status: http.StatusUnauthorized, + body: []byte(`{"status":401,"title":"Unauthorized","detail":"missing ` + readAuthHeader + ` grant"}`), + } + problemReadAuthRejected = problemBody{ + status: http.StatusForbidden, + body: []byte(`{"status":403,"title":"Forbidden","detail":"read grant rejected"}`), + } + problemReadAuthBadPath = problemBody{ + status: http.StatusBadRequest, + body: []byte(`{"status":400,"title":"Bad Request","detail":"invalid characters in request path"}`), + } +) + +// ResolveReadAuthVerifier builds a read-auth verifier from the configured key +// material, preferring the GC_CITY_READ_PUBKEY env over the supplied config +// value. It returns (nil, nil) when no key is configured and read-auth is not +// required. When read-auth is required (configRequired, or +// GC_CITY_READ_REQUIRED=1) but no key is present it returns an error so the +// caller can fail closed at boot rather than serve reads unguarded. +func ResolveReadAuthVerifier(configKey string, configRequired bool) (*citywriteauth.Verifier, error) { + raw := strings.TrimSpace(os.Getenv("GC_CITY_READ_PUBKEY")) + if raw == "" { + raw = strings.TrimSpace(configKey) + } + required := configRequired || os.Getenv("GC_CITY_READ_REQUIRED") == "1" + if raw == "" { + if required { + return nil, errors.New("read-auth required but no verifying key configured") + } + return nil, nil // not enabled + } + keys, err := parseVerifyKeys(raw) + if err != nil { + return nil, err + } + var epochFloor int64 + if e := strings.TrimSpace(os.Getenv("GC_CITY_READ_EPOCH_FLOOR")); e != "" { + epochFloor, err = strconv.ParseInt(e, 10, 64) + if err != nil { + return nil, fmt.Errorf("GC_CITY_READ_EPOCH_FLOOR: %w", err) + } + } + return citywriteauth.New(citywriteauth.Options{ + Aud: readAuthAudience, + Keys: keys, + EpochFloor: epochFloor, + MaxTTL: readAuthMaxTTL, + Skew: readAuthSkew, + }) +} + +// InstallReadAuth resolves the read-auth verifier from config + env and, when +// configured, installs it on sm — the single seam every serve path uses so none +// can forget to gate reads. It fails closed: if read-auth is required +// (configRequired or GC_CITY_READ_REQUIRED=1) but no usable key is configured, +// it returns an error so the caller can refuse to start. +func InstallReadAuth(sm *SupervisorMux, configKey string, configRequired bool) error { + v, err := ResolveReadAuthVerifier(configKey, configRequired) + if err != nil { + return err + } + if v != nil { + sm.WithReadAuth(v) + } + return nil +} diff --git a/internal/api/readauth_test.go b/internal/api/readauth_test.go new file mode 100644 index 0000000000..f01951b615 --- /dev/null +++ b/internal/api/readauth_test.go @@ -0,0 +1,619 @@ +package api + +import ( + "bytes" + "crypto/ed25519" + "encoding/base64" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gastownhall/gascity/internal/citywriteauth" +) + +func newTestReadVerifier(t *testing.T, pub ed25519.PublicKey, now time.Time) *citywriteauth.Verifier { + t.Helper() + v, err := citywriteauth.New(citywriteauth.Options{ + Aud: readAuthAudience, + Keys: map[string]ed25519.PublicKey{"k1": pub}, + MaxTTL: 2 * time.Minute, + Skew: 30 * time.Second, + Now: func() time.Time { return now }, + }) + if err != nil { + t.Fatalf("New read verifier: %v", err) + } + return v +} + +// readGrant mints a read grant bound to a GET/HEAD request. The body is always +// empty for reads, so the digest is computed over nil (== the empty-body hash). +func readGrant(now time.Time, city, method, path, rawQuery, jti string) citywriteauth.Grant { + return citywriteauth.Grant{ + Kid: "k1", Aud: readAuthAudience, City: city, Epoch: 0, + IAT: now.Unix(), Exp: now.Add(30 * time.Second).Unix(), + JTI: jti, Req: citywriteauth.ReqDigest(method, path, rawQuery, nil), + } +} + +// Read-auth is the jurisdiction of GET/HEAD only. A mutation passes straight +// through to the next handler — write-auth (if any) gates it, not this. +func TestReadAuthMiddleware_IgnoresMutations(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + pub, _ := mustKeypair(t) + var seen bool + var got []byte + h := readAuthMiddleware(newTestReadVerifier(t, pub, now), echoNext(&seen, &got)) + req := httptest.NewRequest(http.MethodPost, "/v0/city/acme/agents", bytes.NewReader([]byte(`{}`))) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if !seen || rec.Code != http.StatusOK { + t.Fatalf("POST must pass through read-auth untouched: seen=%v code=%d", seen, rec.Code) + } +} + +func TestReadAuthMiddleware_RejectsMissingGrant(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + pub, _ := mustKeypair(t) + var seen bool + var got []byte + h := readAuthMiddleware(newTestReadVerifier(t, pub, now), echoNext(&seen, &got)) + req := httptest.NewRequest(http.MethodGet, "/v0/city/acme/agents", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if seen { + t.Fatal("handler must not run without a read grant") + } + if rec.Code != http.StatusUnauthorized { + t.Fatalf("code=%d want 401", rec.Code) + } +} + +// A valid GET grant passes and pins the empty-body digest: the minter binds +// ReqDigest("GET", path, "", nil) and the middleware must compute the same. +func TestReadAuthMiddleware_AcceptsValidGrant(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + pub, priv := mustKeypair(t) + path := "/v0/city/acme/beads" + tok := mintToken(t, priv, readGrant(now, "acme", "GET", path, "", "jr1")) + var seen bool + var got []byte + h := readAuthMiddleware(newTestReadVerifier(t, pub, now), echoNext(&seen, &got)) + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set(readAuthHeader, tok) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if !seen || rec.Code != http.StatusOK { + t.Fatalf("valid read grant should pass: seen=%v code=%d", seen, rec.Code) + } +} + +// HEAD is gated like GET, and the method is part of the request binding: a GET +// grant must not authorize a HEAD of the same path. +func TestReadAuthMiddleware_GatesHEAD(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + pub, priv := mustKeypair(t) + path := "/v0/city/acme/beads" + + // HEAD without a grant -> 401. + var seen bool + var got []byte + h := readAuthMiddleware(newTestReadVerifier(t, pub, now), echoNext(&seen, &got)) + req := httptest.NewRequest(http.MethodHead, path, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if seen || rec.Code != http.StatusUnauthorized { + t.Fatalf("HEAD without grant: seen=%v code=%d want 401", seen, rec.Code) + } + + // A GET-bound grant must NOT authorize a HEAD (method is in the preimage). + seen = false + getTok := mintToken(t, priv, readGrant(now, "acme", "GET", path, "", "jhead-get")) + h = readAuthMiddleware(newTestReadVerifier(t, pub, now), echoNext(&seen, &got)) + req = httptest.NewRequest(http.MethodHead, path, nil) + req.Header.Set(readAuthHeader, getTok) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if seen || rec.Code != http.StatusForbidden { + t.Fatalf("GET grant on HEAD: seen=%v code=%d want 403", seen, rec.Code) + } + + // A HEAD-bound grant authorizes the HEAD. + seen = false + headTok := mintToken(t, priv, readGrant(now, "acme", "HEAD", path, "", "jhead-ok")) + h = readAuthMiddleware(newTestReadVerifier(t, pub, now), echoNext(&seen, &got)) + req = httptest.NewRequest(http.MethodHead, path, nil) + req.Header.Set(readAuthHeader, headTok) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if !seen || rec.Code != http.StatusOK { + t.Fatalf("HEAD with matching grant: seen=%v code=%d want 200", seen, rec.Code) + } +} + +// Audience isolation: a write grant (aud gc-city-write) must not authorize a +// read. The read verifier's audience is gc-city-read. +func TestReadAuthMiddleware_RejectsWriteAudienceGrant(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + pub, priv := mustKeypair(t) + path := "/v0/city/acme/beads" + // A grant that would be valid for a write, presented on a read. + writeTok := mintToken(t, priv, citywriteauth.Grant{ + Kid: "k1", Aud: writeAuthAudience, City: "acme", Epoch: 0, + IAT: now.Unix(), Exp: now.Add(30 * time.Second).Unix(), + JTI: "jw1", Req: citywriteauth.ReqDigest("GET", path, "", nil), + }) + var seen bool + var got []byte + h := readAuthMiddleware(newTestReadVerifier(t, pub, now), echoNext(&seen, &got)) + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set(readAuthHeader, writeTok) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if seen || rec.Code != http.StatusForbidden { + t.Fatalf("write-aud grant on read: seen=%v code=%d want 403", seen, rec.Code) + } +} + +// The converse of the aud-isolation guard: a read grant must not authorize a +// write through the write-auth gate. +func TestWriteAuthMiddleware_RejectsReadAudienceGrant(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + pub, priv := mustKeypair(t) + path := "/v0/city/acme/agents" + body := []byte(`{}`) + readTok := mintToken(t, priv, citywriteauth.Grant{ + Kid: "k1", Aud: readAuthAudience, City: "acme", Epoch: 0, + IAT: now.Unix(), Exp: now.Add(30 * time.Second).Unix(), + JTI: "jr-on-w", Req: citywriteauth.ReqDigest("POST", path, "", body), + }) + var seen bool + var got []byte + h := writeAuthMiddleware(newTestWriteVerifier(t, pub, now), false, echoNext(&seen, &got)) + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body)) + req.Header.Set(writeAuthHeader, readTok) + req.Header.Set(csrfHeaderName, "1") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if seen || rec.Code != http.StatusForbidden { + t.Fatalf("read-aud grant on write: seen=%v code=%d want 403", seen, rec.Code) + } +} + +// v1 scope boundary: read-auth gates only the typed /v0/city/{name} reads. +// Supervisor-scope reads, the aggregate event feed, the dashboard host /api/* +// plane (a parallel per-city read surface), static, and the /svc/ pass-through +// all fall through ungated in v1 — pinned here so the boundary is explicit and a +// future narrowing/widening of the grammar is caught. Gating /api/* and +// /v0/events is tracked follow-up under the supervisor-scope grant. +func TestReadAuthMiddleware_PassesThroughNonCityPaths(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + pub, _ := mustKeypair(t) + for _, path := range []string{ + "/v0/cities", + "/health", + "/openapi.json", + "/v0/events", // supervisor-scope aggregate feed (deferred) + "/v0/events/stream", // supervisor-scope aggregate SSE (deferred) + "/api/city/acme/supervisor-status", // dashboard host plane per-city read (deferred) + "/api/city/acme/runs/r-1/detail", // dashboard host plane per-city read (deferred) + "/v0/city/acme/svc/foo", + "/v0/city/acme/", // empty sub-resource + "/v0/city", + } { + t.Run(path, func(t *testing.T) { + var seen bool + var got []byte + h := readAuthMiddleware(newTestReadVerifier(t, pub, now), echoNext(&seen, &got)) + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if !seen { + t.Fatalf("%s must pass through read-auth (not city-scoped): code=%d", path, rec.Code) + } + }) + } +} + +// The query string is part of the read binding: a grant for one query variant +// must not authorize another, and reordered params still verify. +func TestReadAuthMiddleware_QueryBound(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + pub, priv := mustKeypair(t) + const path = "/v0/city/acme/beads" + + run := func(t *testing.T, tok, target string) (seen bool, code int) { + t.Helper() + var got []byte + h := readAuthMiddleware(newTestReadVerifier(t, pub, now), echoNext(&seen, &got)) + req := httptest.NewRequest(http.MethodGet, target, nil) + if tok != "" { + req.Header.Set(readAuthHeader, tok) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return seen, rec.Code + } + + t.Run("scoped grant cannot be widened by dropping the query", func(t *testing.T) { + tok := mintToken(t, priv, readGrant(now, "acme", "GET", path, "status=open", "jq1")) + if seen, code := run(t, tok, path); seen || code != http.StatusForbidden { + t.Fatalf("query drop: seen=%v code=%d want 403", seen, code) + } + }) + t.Run("matching query authorizes", func(t *testing.T) { + tok := mintToken(t, priv, readGrant(now, "acme", "GET", path, "status=open", "jq2")) + if seen, code := run(t, tok, path+"?status=open"); !seen || code != http.StatusOK { + t.Fatalf("matching query: seen=%v code=%d want 200", seen, code) + } + }) + t.Run("query order independent", func(t *testing.T) { + tok := mintToken(t, priv, readGrant(now, "acme", "GET", path, "a=1&b=2", "jq3")) + if seen, code := run(t, tok, path+"?b=2&a=1"); !seen || code != http.StatusOK { + t.Fatalf("reordered query: seen=%v code=%d want 200", seen, code) + } + }) +} + +// SSE stream endpoints are city-scoped GETs and are gated at connect (admission). +func TestReadAuthMiddleware_GatesSSEAdmission(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + pub, priv := mustKeypair(t) + path := "/v0/city/acme/events/stream" + + // No grant -> 401 at admission. + var seen bool + var got []byte + h := readAuthMiddleware(newTestReadVerifier(t, pub, now), echoNext(&seen, &got)) + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if seen || rec.Code != http.StatusUnauthorized { + t.Fatalf("SSE without grant: seen=%v code=%d want 401", seen, rec.Code) + } + + // Valid grant -> admitted. + seen = false + tok := mintToken(t, priv, readGrant(now, "acme", "GET", path, "", "jsse")) + h = readAuthMiddleware(newTestReadVerifier(t, pub, now), echoNext(&seen, &got)) + req = httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set(readAuthHeader, tok) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if !seen || rec.Code != http.StatusOK { + t.Fatalf("SSE with grant: seen=%v code=%d want 200", seen, rec.Code) + } +} + +func TestReadAuthMiddleware_RejectsControlCharPath(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + pub, _ := mustKeypair(t) + var seen bool + var got []byte + h := readAuthMiddleware(newTestReadVerifier(t, pub, now), echoNext(&seen, &got)) + req := httptest.NewRequest(http.MethodGet, "/v0/city/acme/beads", nil) + req.URL.Path = "/v0/city/acme/beads\nx" // decoded %0A in path + req.Header.Set(readAuthHeader, "bogus") // path check fires before token checks + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if seen || rec.Code != http.StatusBadRequest { + t.Fatalf("control-char path: seen=%v code=%d want 400", seen, rec.Code) + } +} + +func TestReadAuthMiddleware_RejectsReplay(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + pub, priv := mustKeypair(t) + path := "/v0/city/acme/beads" + tok := mintToken(t, priv, readGrant(now, "acme", "GET", path, "", "jrep")) + v := newTestReadVerifier(t, pub, now) + do := func() int { + var seen bool + var got []byte + h := readAuthMiddleware(v, echoNext(&seen, &got)) + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set(readAuthHeader, tok) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec.Code + } + if code := do(); code != http.StatusOK { + t.Fatalf("first: code=%d want 200", code) + } + if code := do(); code != http.StatusForbidden { + t.Fatalf("replay: code=%d want 403", code) + } +} + +func TestResolveReadAuthVerifier(t *testing.T) { + pub, _ := mustKeypair(t) + b64 := base64.StdEncoding.EncodeToString(pub) + + t.Run("not enabled returns nil", func(t *testing.T) { + t.Setenv("GC_CITY_READ_PUBKEY", "") + t.Setenv("GC_CITY_READ_REQUIRED", "") + v, err := ResolveReadAuthVerifier("", false) + if err != nil || v != nil { + t.Fatalf("want (nil,nil) got (%v,%v)", v, err) + } + }) + t.Run("env key enables", func(t *testing.T) { + t.Setenv("GC_CITY_READ_PUBKEY", "k1:"+b64) + t.Setenv("GC_CITY_READ_REQUIRED", "") + v, err := ResolveReadAuthVerifier("", false) + if err != nil || v == nil { + t.Fatalf("env key should enable: (%v,%v)", v, err) + } + }) + t.Run("config fallback when env empty", func(t *testing.T) { + t.Setenv("GC_CITY_READ_PUBKEY", "") + t.Setenv("GC_CITY_READ_REQUIRED", "") + v, err := ResolveReadAuthVerifier("k1:"+b64, false) + if err != nil || v == nil { + t.Fatalf("config key should enable: (%v,%v)", v, err) + } + }) + t.Run("env required but missing errors (fail-closed boot)", func(t *testing.T) { + t.Setenv("GC_CITY_READ_PUBKEY", "") + t.Setenv("GC_CITY_READ_REQUIRED", "1") + if _, err := ResolveReadAuthVerifier("", false); err == nil { + t.Fatal("env-required + missing key must error") + } + }) + t.Run("config required but missing errors (fail-closed boot)", func(t *testing.T) { + t.Setenv("GC_CITY_READ_PUBKEY", "") + t.Setenv("GC_CITY_READ_REQUIRED", "") + if _, err := ResolveReadAuthVerifier("", true); err == nil { + t.Fatal("config-required + missing key must error") + } + }) +} + +func TestInstallReadAuth(t *testing.T) { + pub, _ := mustKeypair(t) + b64 := base64.StdEncoding.EncodeToString(pub) + + t.Run("installs the gate when a key is configured", func(t *testing.T) { + t.Setenv("GC_CITY_READ_PUBKEY", "") + t.Setenv("GC_CITY_READ_REQUIRED", "") + sm := NewSupervisorMux(nil, nil, false, "t", "", time.Now()) + if err := InstallReadAuth(sm, "k1:"+b64, false); err != nil { + t.Fatalf("install: %v", err) + } + if sm.readAuth == nil { + t.Fatal("read verifier was not installed") + } + }) + t.Run("no-op when unconfigured", func(t *testing.T) { + t.Setenv("GC_CITY_READ_PUBKEY", "") + t.Setenv("GC_CITY_READ_REQUIRED", "") + sm := NewSupervisorMux(nil, nil, false, "t", "", time.Now()) + if err := InstallReadAuth(sm, "", false); err != nil { + t.Fatalf("install: %v", err) + } + if sm.readAuth != nil { + t.Fatal("gate should not be installed when unconfigured") + } + }) + t.Run("errors when required but missing", func(t *testing.T) { + t.Setenv("GC_CITY_READ_PUBKEY", "") + t.Setenv("GC_CITY_READ_REQUIRED", "") + sm := NewSupervisorMux(nil, nil, false, "t", "", time.Now()) + if err := InstallReadAuth(sm, "", true); err == nil { + t.Fatal("expected fail-closed error") + } + }) +} + +// End-to-end through the full SupervisorMux middleware chain: a city-scoped read +// with no grant is rejected before dispatch when read-auth is installed. +func TestSupervisorMux_ReadAuthGuardsRead(t *testing.T) { + pub, _ := mustKeypair(t) + v := newTestReadVerifier(t, pub, time.Now()) + sm := NewSupervisorMux(nil, nil, false, "test", "", time.Now()). + WithAnyHostAllowed(). + WithReadAuth(v) + + srv := httptest.NewServer(sm.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/v0/city/acme/beads") + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("read without grant: status=%d want 401", resp.StatusCode) + } +} + +// Opt-in/off-by-default: with no key configured the read gate is not installed, +// so a first-party city read is never turned away for a missing grant. +func TestSupervisorMux_NoReadAuthAllowsOpenReads(t *testing.T) { + sm := NewSupervisorMux(nil, nil, false, "test", "", time.Now()). + WithAnyHostAllowed() + if sm.readAuth != nil { + t.Fatal("read-auth must be disabled when no key is configured") + } + + srv := httptest.NewServer(sm.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/v0/city/acme/beads") + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + // Gate is off: whatever the backend-less downstream returns, it must not be + // the read-auth missing-grant rejection. + if resp.StatusCode == http.StatusUnauthorized { + body, _ := io.ReadAll(resp.Body) + if bytes.Contains(body, []byte(readAuthHeader)) { + t.Fatalf("first-party read gated by read-auth when no key configured: %s", body) + } + } +} + +// A read grant bound to a different city must not authorize a read of this one: +// the City claim is part of the verified expectation (mirror of the write gate's +// RejectsWrongCity). +func TestReadAuthMiddleware_RejectsWrongCity(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + pub, priv := mustKeypair(t) + path := "/v0/city/acme/beads" + // Digest binds the real path; only the City claim is wrong. + tok := mintToken(t, priv, readGrant(now, "other", "GET", path, "", "jwc")) + var seen bool + var got []byte + h := readAuthMiddleware(newTestReadVerifier(t, pub, now), echoNext(&seen, &got)) + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set(readAuthHeader, tok) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if seen || rec.Code != http.StatusForbidden { + t.Fatalf("wrong city: seen=%v code=%d want 403", seen, rec.Code) + } +} + +// The prior middleware tests build verifiers directly. This drives the gate +// through the PRODUCTION ResolveReadAuthVerifier path (env key, real clock, +// audience, and epoch floor) so the wiring — not just the test harness — is +// covered. +func TestReadAuthMiddleware_WithResolvedVerifier(t *testing.T) { + pub, priv := mustKeypair(t) + b64 := base64.StdEncoding.EncodeToString(pub) + path := "/v0/city/acme/beads" + + // mintReal signs a grant against the real clock (the resolved verifier uses + // time.Now, so a fixed test clock would fall outside its skew window). + mintReal := func(aud string, epoch int64, jti string) string { + now := time.Now() + return mintToken(t, priv, citywriteauth.Grant{ + Kid: "k1", Aud: aud, City: "acme", Epoch: epoch, + IAT: now.Unix(), Exp: now.Add(30 * time.Second).Unix(), + JTI: jti, Req: citywriteauth.ReqDigest("GET", path, "", nil), + }) + } + drive := func(t *testing.T, v *citywriteauth.Verifier, tok string) (seen bool, code int) { + t.Helper() + var got []byte + h := readAuthMiddleware(v, echoNext(&seen, &got)) + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set(readAuthHeader, tok) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return seen, rec.Code + } + + t.Run("resolved read verifier accepts a gc-city-read grant", func(t *testing.T) { + t.Setenv("GC_CITY_READ_PUBKEY", "k1:"+b64) + t.Setenv("GC_CITY_READ_REQUIRED", "") + t.Setenv("GC_CITY_READ_EPOCH_FLOOR", "") + v, err := ResolveReadAuthVerifier("", false) + if err != nil || v == nil { + t.Fatalf("resolve: (%v,%v)", v, err) + } + if seen, code := drive(t, v, mintReal(readAuthAudience, 0, "jrv-ok")); !seen || code != http.StatusOK { + t.Fatalf("resolved-verifier read grant: seen=%v code=%d want 200", seen, code) + } + }) + + t.Run("resolved read verifier rejects a write-audience grant", func(t *testing.T) { + t.Setenv("GC_CITY_READ_PUBKEY", "k1:"+b64) + t.Setenv("GC_CITY_READ_REQUIRED", "") + t.Setenv("GC_CITY_READ_EPOCH_FLOOR", "") + v, err := ResolveReadAuthVerifier("", false) + if err != nil || v == nil { + t.Fatalf("resolve: (%v,%v)", v, err) + } + if seen, code := drive(t, v, mintReal(writeAuthAudience, 0, "jrv-wrongaud")); seen || code != http.StatusForbidden { + t.Fatalf("write-aud on resolved read verifier: seen=%v code=%d want 403", seen, code) + } + }) + + t.Run("epoch floor revokes grants below the floor", func(t *testing.T) { + t.Setenv("GC_CITY_READ_PUBKEY", "k1:"+b64) + t.Setenv("GC_CITY_READ_REQUIRED", "") + t.Setenv("GC_CITY_READ_EPOCH_FLOOR", "5") + v, err := ResolveReadAuthVerifier("", false) + if err != nil || v == nil { + t.Fatalf("resolve: (%v,%v)", v, err) + } + if seen, code := drive(t, v, mintReal(readAuthAudience, 4, "jrv-e4")); seen || code != http.StatusForbidden { + t.Fatalf("epoch 4 below floor 5: seen=%v code=%d want 403", seen, code) + } + if seen, code := drive(t, v, mintReal(readAuthAudience, 5, "jrv-e5")); !seen || code != http.StatusOK { + t.Fatalf("epoch 5 at floor 5: seen=%v code=%d want 200", seen, code) + } + }) +} + +// End-to-end acceptance + single-use through the full SupervisorMux chain: a +// valid read grant clears the gate (the backend-less downstream then 404s, which +// is fine), and re-presenting the single-use token is rejected as a replay. +func TestSupervisorMux_ReadAuthAcceptsValidGrant(t *testing.T) { + now := time.Now() + pub, priv := mustKeypair(t) + sm := NewSupervisorMux(nil, nil, false, "test", "", now). + WithAnyHostAllowed(). + WithReadAuth(newTestReadVerifier(t, pub, now)) + srv := httptest.NewServer(sm.Handler()) + defer srv.Close() + + const target = "/v0/city/acme/beads" + tok := mintToken(t, priv, readGrant(now, "acme", "GET", target, "status=open", "je2e")) + + do := func() int { + req, err := http.NewRequest(http.MethodGet, srv.URL+target+"?status=open", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set(readAuthHeader, tok) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer func() { _ = resp.Body.Close() }() + return resp.StatusCode + } + + // First presentation clears the gate (not a read-auth rejection). + if code := do(); code == http.StatusUnauthorized || code == http.StatusForbidden { + t.Fatalf("valid read grant should clear the gate, got %d", code) + } + // Single-use: the same token replayed is rejected. + if code := do(); code != http.StatusForbidden { + t.Fatalf("replayed read grant: code=%d want 403", code) + } +} + +// Reads must keep working in read-only mode — the default posture of the +// non-localhost binds that will enable read-auth. A valid read grant clears both +// gates even when readOnly is true (which only refuses mutations). +func TestSupervisorMux_ReadAuthPassesInReadOnlyMode(t *testing.T) { + now := time.Now() + pub, priv := mustKeypair(t) + sm := NewSupervisorMux(nil, nil, true /* readOnly */, "test", "", now). + WithAnyHostAllowed(). + WithReadAuth(newTestReadVerifier(t, pub, now)) + srv := httptest.NewServer(sm.Handler()) + defer srv.Close() + + const target = "/v0/city/acme/beads" + tok := mintToken(t, priv, readGrant(now, "acme", "GET", target, "", "jro")) + req, err := http.NewRequest(http.MethodGet, srv.URL+target, nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set(readAuthHeader, tok) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + t.Fatalf("read in read-only mode must clear the gate, got %d", resp.StatusCode) + } +} diff --git a/internal/api/supervisor.go b/internal/api/supervisor.go index 1b7a9db240..48ed5e61f5 100644 --- a/internal/api/supervisor.go +++ b/internal/api/supervisor.go @@ -116,6 +116,7 @@ type SupervisorMux struct { allowedHosts []string allowAnyHost bool writeAuth *citywriteauth.Verifier + readAuth *citywriteauth.Verifier server *http.Server // Single Huma API (Phase 3.5 — Topology 1). Owns every typed @@ -231,6 +232,13 @@ func (sm *SupervisorMux) Handler() http.Handler { if sm.writeAuth != nil { root = writeAuthMiddleware(sm.writeAuth, sm.readOnly, root) } + // When a verifying key is configured, gate city-scoped reads on a signed + // grant. Disjoint from the write gate by method (GET/HEAD vs mutations), so + // the relative wrap order is correctness-irrelevant; both stay innermost + // (after host/CORS) so preflight and host rejection never need a grant. + if sm.readAuth != nil { + root = readAuthMiddleware(sm.readAuth, root) + } audit := requestAuditConfig{ recorder: sm.supervisorEventRecorder(), allowedOrigins: sm.allowedOrigins, @@ -295,6 +303,15 @@ func (sm *SupervisorMux) WithWriteAuth(v *citywriteauth.Verifier) *SupervisorMux return sm } +// WithReadAuth installs the read-auth verifier so city-scoped reads (GET/HEAD) +// are gated on a signed grant, and rebuilds the internal http.Server handler. A +// nil verifier leaves read-auth disabled. Must be called before Serve. +func (sm *SupervisorMux) WithReadAuth(v *citywriteauth.Verifier) *SupervisorMux { + sm.readAuth = v + sm.server = &http.Server{Handler: sm.Handler()} + return sm +} + // WithAnyHostAllowed disables Host header validation. This preserves the // legacy standalone city API behavior; machine-wide supervisor mode should // keep Host validation enabled and use WithAllowedHosts for explicit names. diff --git a/internal/api/writeauth.go b/internal/api/writeauth.go index 91066b629f..08d4d88907 100644 --- a/internal/api/writeauth.go +++ b/internal/api/writeauth.go @@ -44,26 +44,20 @@ const ( writeAuthSkew = 30 * time.Second ) -// cityScopedObjectMutation reports whether path targets an existing city whose -// config the write-auth gate must cover, returning the city name. It matches the -// per-city typed gc routes: /v0/city/{cityName} (the suspend/resume PATCH) and +// cityScopedObjectPath is the shared path grammar for the city-scoped auth gates +// (write-auth and read-auth), returning the city name. It matches the per-city +// typed gc routes: /v0/city/{cityName} (the suspend/resume PATCH) and // /v0/city/{cityName}/. It excludes: -// - registry creation (POST /v0/city) and the bare /v0/city/ (empty name): a -// grant binds a path-resident city name, so creating a city — which carries -// no city in its path yet — stays governed by the prior supervisor-registry -// guards, not this gate. Write-auth covers mutations of cities that already -// exist (including unregister, which does carry the city in its path). -// - any other non-city path, +// - the bare /v0/city/ (empty name) and any non-city path, // - an empty sub-resource (/v0/city/{name}/), -// - the /svc/ workspace-service pass-through, which cannot mutate gc config -// objects and applies its own publication rules. +// - the /svc/ workspace-service pass-through, which applies its own +// publication rules. // -// The /hook/ webhook receiver is deliberately NOT exempted (the H2 reversal): a -// /hook/{name} POST dispatches order → sh -c authenticated by a verifier a pack -// may author, so when write-auth is configured it stays gated on the operator's -// signed grant. Signature verification (E4) is an ADDITIONAL gate for public -// webhooks, never a replacement for this one. Do not add a /hook/ exemption here. -func cityScopedObjectMutation(path string) (city string, ok bool) { +// It matches on path only; the caller applies the method policy (write-auth +// gates mutations; read-auth gates GET/HEAD). Registry creation (POST /v0/city) +// carries no path-resident city and so does not match here — see the +// method-policy callers for the carve-out rationale. +func cityScopedObjectPath(path string) (city string, ok bool) { const prefix = "/v0/city/" if !strings.HasPrefix(path, prefix) { return "", false @@ -92,6 +86,25 @@ func cityScopedObjectMutation(path string) (city string, ok bool) { return city, true } +// cityScopedObjectMutation reports whether path targets an existing city whose +// config the write-auth gate must cover, returning the city name. It shares the +// grammar in cityScopedObjectPath; the write gate additionally restricts by +// method (mutations only). Notes on the write-side carve-outs: +// - registry creation (POST /v0/city) carries no path-resident city name, so +// creating a city stays governed by the prior supervisor-registry guards, +// not this gate. Write-auth covers mutations of cities that already exist +// (including unregister, which does carry the city in its path). +// - the /svc/ workspace-service pass-through is exempt (shared grammar). +// +// The /hook/ webhook receiver is deliberately NOT exempted (the H2 reversal): a +// /hook/{name} POST dispatches order → sh -c authenticated by a verifier a pack +// may author, so when write-auth is configured it stays gated on the operator's +// signed grant. Signature verification (E4) is an ADDITIONAL gate for public +// webhooks, never a replacement for this one. Do not add a /hook/ exemption here. +func cityScopedObjectMutation(path string) (city string, ok bool) { + return cityScopedObjectPath(path) +} + // writeAuthMiddleware enforces a valid X-GC-City-Write grant on every // city-scoped mutation. Non-mutations and non-city-scoped routes pass through // untouched. It buffers and resets the body so the downstream handler still @@ -229,19 +242,19 @@ func parseVerifyKeys(s string) (map[string]ed25519.PublicKey, error) { kid, b64, ok := strings.Cut(part, ":") kid = strings.TrimSpace(kid) if !ok || kid == "" { - return nil, fmt.Errorf("write-auth key %q: want kid:base64", part) + return nil, fmt.Errorf("verify key %q: want kid:base64", part) } raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) if err != nil { - return nil, fmt.Errorf("write-auth key %q: %w", kid, err) + return nil, fmt.Errorf("verify key %q: %w", kid, err) } if len(raw) != ed25519.PublicKeySize { - return nil, fmt.Errorf("write-auth key %q: wrong public-key size %d", kid, len(raw)) + return nil, fmt.Errorf("verify key %q: wrong public-key size %d", kid, len(raw)) } keys[kid] = ed25519.PublicKey(raw) } if len(keys) == 0 { - return nil, errors.New("write-auth: no verifying keys parsed") + return nil, errors.New("no verifying keys parsed") } return keys, nil } diff --git a/internal/config/config.go b/internal/config/config.go index d1c39c8d24..363a42cf66 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2053,6 +2053,44 @@ type APIConfig struct { // gate writes fails closed if the key is ever dropped. The // GC_CITY_WRITE_REQUIRED=1 env var has the same effect. WriteAuthRequired bool `toml:"write_auth_required,omitempty"` + // ReadAuthVerifyKey, when set, requires every read (GET/HEAD) of an + // already-registered city on the typed per-city API — the routes under + // /v0/city/{cityName} — to carry a signed read grant from a configured + // trusted authority. It is the read-side twin of WriteAuthVerifyKey, adding + // in-process, grant-based admission control to the typed city read surface + // (beads, mail, sessions, agent transcripts) instead of trusting network + // position. + // + // Scope boundary: this gate covers ONLY the typed /v0/city/{cityName} read + // routes. It does NOT cover other surfaces on the same listener that can also + // expose per-city data: the supervisor-scope aggregate event feed (/v0/events + // and /v0/events/stream, which multiplex every running city's events), the + // default-on dashboard host plane (/api/*, including its /api/city/{cityName}/* + // samplers, run detail, run diff, and config reads), and the supervisor-scope + // routes /v0/cities, /health, /v0/readiness, /v0/provider-readiness, the + // OpenAPI document, and the dashboard SPA shell. On a non-localhost bind, the + // only complete mitigation is to front the whole listener with the + // grant-minting authority/edge (the intended deployment), which protects + // every surface above. Disabling the dashboard host plane with + // GC_SUPERVISOR_DASHBOARD=0 is additive, not a substitute: it closes /api/* + // only, while the supervisor-scope event feed /v0/events and + // /v0/events/stream stays readable by network position until the follow-up + // supervisor-scope grant lands. Gating those feeds is tracked as that + // follow-up work. + // + // Built-in callers (the bundled gc API client and dashboard SPA) mint no + // grant, so enabling this gate turns their direct /v0/city reads away with a + // clear 401; such deployments front reads through the authority that mints + // grants. The value is one or more "kid:base64-ed25519-pubkey" entries, comma + // separated. The GC_CITY_READ_PUBKEY env var overrides this. Grant revocation + // via an epoch floor is an ops-plane control set only through the + // GC_CITY_READ_EPOCH_FLOOR env var; it has no config field. + ReadAuthVerifyKey string `toml:"read_auth_verify_key,omitempty"` + // ReadAuthRequired makes a missing or empty ReadAuthVerifyKey a startup error + // instead of silently disabling the gate, so a config that intends to gate + // reads fails closed if the key is ever dropped. The GC_CITY_READ_REQUIRED=1 + // env var has the same effect. + ReadAuthRequired bool `toml:"read_auth_required,omitempty"` } // BindOrDefault returns the bind address, defaulting to "127.0.0.1". diff --git a/internal/supervisor/config.go b/internal/supervisor/config.go index c19b48a5af..5a120e551b 100644 --- a/internal/supervisor/config.go +++ b/internal/supervisor/config.go @@ -46,6 +46,12 @@ type Section struct { // and full semantics. WriteAuthVerifyKey string `toml:"write_auth_verify_key,omitempty"` WriteAuthRequired bool `toml:"write_auth_required,omitempty"` + // ReadAuthVerifyKey / ReadAuthRequired require a signed read grant on every + // read (GET/HEAD) of an already-registered city (the per-city routes under + // /v0/city/{cityName}); supervisor-scope reads (/v0/cities, /health) stay + // open. See config.APIConfig for the key format and full semantics. + ReadAuthVerifyKey string `toml:"read_auth_verify_key,omitempty"` + ReadAuthRequired bool `toml:"read_auth_required,omitempty"` } // PublicationConfig holds machine-wide publication policy for workspace From e72e34771e7963c3b8d5854188bb528738f5ccf2 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Thu, 9 Jul 2026 02:27:18 -0700 Subject: [PATCH 029/225] simplify(S20): throttled unknown-state signal + escalation for the session reconciler (#4025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What this does Lands **S20** — turns the session reconciler's forward-compat unknown-state skip from a silent per-tick stderr storm into a durable, throttled signal — and folds the review follow-up + completes the OpenAPI change the source commit left half-done. **Commit 1** (`simplify(S20)`, slice-a, signal-only): new typed event `session.unknown_state` (constant + `KnownEventTypes`) with a registered `api.SessionUnknownStatePayload`; `emitSessionUnknownStateDiagnostic` reuses the stranded-diagnostic throttle-marker pattern — durable `unknown_state_first_seen` / `_value` / `_escalated_at` markers, emit only on first sight or a changed unrecognized value (#2389), survives restarts (#2085), and one escalated re-emit past 30m (#1497). No state mutation; recovery stays with operators/pack subscribers. **Commit 2** (folded follow-up + genclient regen): - **Follow-up** — clear the unknown-state markers when a session recovers to a known state (`clearSessionUnknownStateMarkers`, called on the known-state path). The markers are durable, so without this a later recurrence of the *same* unrecognized value would look like "same state as last tick" and be silently suppressed — the recurrence would never re-signal. No-op (no write) when the session carries no markers. Covered by `TestClearSessionUnknownStateMarkers_RecurrenceReemitsAfterRecovery`. - **genclient regen** — the source commit added the event payload to `internal/api/openapi.json` but did not regenerate the Go client, so `TestGeneratedClientInSync` (CI: `preflight-generated` → `spec-ci`) was red. Ran `go generate ./internal/api/genclient` to add the `SessionUnknownStatePayload` client types. The vendored dashboard hey-api client (`types.gen.ts`) is sourced from the external `gascity-dashboard` repo and is not regenerated/drift-checked by this repo's `dashboard-ci` gate, so it is intentionally untouched. ## Deferred Slice-b — the actual typed `SessionState` enum simplification the title promises — is deferred and filed as bead **ga-cx470v** so it isn't lost. ## Gates - `go build ./...` — pass - `go vet ./cmd/gc ./internal/api ./internal/api/genclient ./internal/events` — pass - `go test ./internal/api ./internal/events ./internal/api/genclient` — pass (incl. `TestGeneratedClientInSync`, `TestOpenAPISpecInSync`, `TestEveryKnownEventTypeHasRegisteredPayload`) - `go test ./cmd/gc` reconciler + unknown-state suite (`-run`) — pass ## Review verdict APPROVED — land with the follow-up folded into the same PR (per the simplification walkthrough). Routed via label PR (`status/needs-review-auto`) because it touches the typed-event / OpenAPI wire surface. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/gc/session_reconciler.go | 151 ++++++++++++++- cmd/gc/session_reconciler_test.go | 271 +++++++++++++++++++++++++++ docs/reference/schema/openapi.json | 150 +++++++++++++++ docs/reference/schema/openapi.txt | 150 +++++++++++++++ internal/api/event_payloads.go | 36 ++++ internal/api/genclient/client_gen.go | 135 +++++++++++++ internal/api/openapi.json | 150 +++++++++++++++ internal/events/events.go | 10 + 8 files changed, 1051 insertions(+), 2 deletions(-) diff --git a/cmd/gc/session_reconciler.go b/cmd/gc/session_reconciler.go index 5cb1adc871..eb1c08ce5a 100644 --- a/cmd/gc/session_reconciler.go +++ b/cmd/gc/session_reconciler.go @@ -1581,8 +1581,7 @@ func reconcileSessionBeadsTracedWithNamedDemand( // rollback: if a newer version writes "draining" or "archived", the // older reconciler ignores those beads rather than crashing. if !isKnownStateInfo(info) { - fmt.Fprintf(stderr, "session reconciler: skipping %s with unknown state %q\n", //nolint:errcheck // best-effort stderr - info.SessionNameMetadata, info.MetadataState) + emitSessionUnknownStateDiagnostic(store, session, info, rec, clk, stderr) if trace != nil { trace.RecordDecision(TraceSiteReconcilerUnknownState, TraceReasonUnknownStateSkipped, TraceOutcomeSkipped, info.Template, info.SessionNameMetadata, traceRecordPayload{ "state": info.MetadataState, @@ -1590,6 +1589,10 @@ func reconcileSessionBeadsTracedWithNamedDemand( } continue } + // Back in a known state: drop any stale unknown-state throttle markers so a + // later recurrence of the same unrecognized value is signaled afresh rather + // than suppressed as "same state as last tick" (no-op when unmarked). + clearSessionUnknownStateMarkers(store, session, stderr) // Orphan/suspended: bead exists but not in desired state. // Handle BEFORE heal/stability to avoid false crash detection — @@ -3832,6 +3835,150 @@ const strandedEventEmittedKey = "stranded_event_emitted_at" // "+N more" so a runaway count doesn't produce an unbounded message. const strandedWorkIDListLimit = 10 +// Unknown-state throttle markers. unknownStateFirstSeenKey records when the +// reconciler first observed the current unrecognized state and +// unknownStateValueKey records the raw value it was seen with; together they +// gate the diagnostic to first sight and state transitions (not every tick, +// #2389) and survive reconciler restarts (#2085) so the first-seen clock and +// its escalation are queryable off the bead (#1497). unknownStateEscalatedKey +// guards the single past-threshold escalation emit. +const ( + unknownStateFirstSeenKey = "unknown_state_first_seen" + unknownStateValueKey = "unknown_state_value" + unknownStateEscalatedKey = "unknown_state_escalated_at" +) + +// unknownStateEscalationAge is how long a session bead may sit in an +// unrecognized state before the reconciler re-emits session.unknown_state with +// escalated=true. The forward-compat skip still defers all action; escalation +// is a signal for operators and pack-level subscribers, never an auto-mutation. +const unknownStateEscalationAge = 30 * time.Minute + +// emitSessionUnknownStateDiagnostic surfaces a session bead whose metadata +// state the reconciler does not recognize. The caller preserves the +// forward-compatible skip (an older reconciler ignores a newer writer's state +// rather than crashing); this turns the previously per-tick stderr line into a +// throttled signal. It logs and records events.SessionUnknownState only on +// first sight or when the raw state changes to a different unrecognized value, +// then re-records once with escalated=true after the bead has sat unrecognized +// past unknownStateEscalationAge. Throttle markers are stamped durably via the +// session front door so the throttle and the escalation clock survive +// reconciler restarts. It never mutates session state — escalation is a +// notification, not a recovery action (keep judgment out of Go). +func emitSessionUnknownStateDiagnostic( + store beads.Store, + session *beads.Bead, + info sessionpkg.Info, + rec events.Recorder, + clk clock.Clock, + stderr io.Writer, +) { + if session == nil { + return + } + if session.Metadata == nil { + session.Metadata = make(map[string]string, 3) + } + // Report the raw, untrimmed state: classification (isKnownStateInfo) keys off + // the raw value, so a known value wrapped in whitespace like " active " is + // skipped as unrecognized and must surface verbatim, not trimmed to "active". + // This matches SessionUnknownStatePayload.State's documented "raw ... value" + // contract and the raw comparison the transition/value markers below use. + state := info.MetadataState + name := strings.TrimSpace(info.SessionNameMetadata) + now := clk.Now().UTC() + + setMarker := func(key, value string) { + session.Metadata[key] = value + if err := sessionFrontDoor(store).SetMarker(session.ID, key, value); err != nil { + fmt.Fprintf(stderr, "session reconciler: stamping unknown-state marker %s on %s: %v\n", key, session.ID, err) //nolint:errcheck // best-effort stderr + } + } + emit := func(escalated bool, firstSeen time.Time) { + if rec == nil { + return + } + age := now.Sub(firstSeen).Round(time.Second) + msg := fmt.Sprintf("session %q has unrecognized state %q; reconciler is skipping it (forward-compatible rollback)", name, state) + if escalated { + msg = fmt.Sprintf("session %q still has unrecognized state %q after %s; reconciler continues to skip it — operator or pack recovery required", name, state, age) + } + rec.Record(events.Event{ + Type: events.SessionUnknownState, + Ts: now, + Actor: "gc", + Subject: session.ID, + Message: msg, + SessionID: session.ID, + Payload: api.SessionUnknownStatePayloadJSON(session.ID, name, state, firstSeen, escalated), + }) + } + + firstSeenRaw := strings.TrimSpace(session.Metadata[unknownStateFirstSeenKey]) + transition := firstSeenRaw == "" || session.Metadata[unknownStateValueKey] != info.MetadataState + if transition { + // First sight, or the unrecognized state changed to a different value: + // (re)stamp the first-seen clock, log once, emit, and clear any prior + // escalation guard so the new state gets its own escalation window. + fmt.Fprintf(stderr, "session reconciler: skipping %s with unknown state %q\n", name, state) //nolint:errcheck // best-effort stderr + emit(false, now) + setMarker(unknownStateFirstSeenKey, now.Format(time.RFC3339)) + setMarker(unknownStateValueKey, info.MetadataState) + if strings.TrimSpace(session.Metadata[unknownStateEscalatedKey]) != "" { + setMarker(unknownStateEscalatedKey, "") + } + return + } + + // Same unrecognized state as a previous tick: stay silent unless it has now + // aged past the escalation threshold and has not escalated yet. + if strings.TrimSpace(session.Metadata[unknownStateEscalatedKey]) != "" { + return + } + firstSeen, err := time.Parse(time.RFC3339, firstSeenRaw) + if err != nil || now.Sub(firstSeen) < unknownStateEscalationAge { + return + } + emit(true, firstSeen) + setMarker(unknownStateEscalatedKey, now.Format(time.RFC3339)) +} + +// clearSessionUnknownStateMarkers removes the unknown-state throttle markers +// once a session is observed back in a known state. The markers are durable +// (they survive reconciler restarts by design), so without clearing them on +// recovery a later recurrence of the *same* unrecognized value would look like +// "same state as the last tick" to emitSessionUnknownStateDiagnostic and be +// silently suppressed — the recurrence would never re-signal. Clearing on the +// known-state path means a recurrence is treated as a fresh first-sight. It is +// a no-op (no in-memory change, no store write) when the session carries no +// unknown-state markers, so the common known-state tick pays nothing. +func clearSessionUnknownStateMarkers(store beads.Store, session *beads.Bead, stderr io.Writer) { + if session == nil || session.Metadata == nil { + return + } + markerKeys := [...]string{unknownStateFirstSeenKey, unknownStateValueKey, unknownStateEscalatedKey} + hasMarker := false + for _, key := range markerKeys { + if strings.TrimSpace(session.Metadata[key]) != "" { + hasMarker = true + break + } + } + if !hasMarker { + return + } + front := sessionFrontDoor(store) + for _, key := range markerKeys { + if strings.TrimSpace(session.Metadata[key]) == "" { + continue + } + session.Metadata[key] = "" + if err := front.SetMarker(session.ID, key, ""); err != nil { + fmt.Fprintf(stderr, "session reconciler: clearing unknown-state marker %s on %s: %v\n", key, session.ID, err) //nolint:errcheck // best-effort stderr + } + } +} + // emitSessionStrandedDiagnostic records a session.stranded event when // the reconciler observes a pool-managed session bead that is no // longer alive but still has open in_progress work assigned. Throttled diff --git a/cmd/gc/session_reconciler_test.go b/cmd/gc/session_reconciler_test.go index 937b0b94ac..79894ec098 100644 --- a/cmd/gc/session_reconciler_test.go +++ b/cmd/gc/session_reconciler_test.go @@ -2148,6 +2148,277 @@ func emitStrandedDiagnosticForTest(t *testing.T, store beads.Store, session *bea return rec } +// unknownStateEvents returns the captured events.SessionUnknownState events in +// emission order. +func (c *capturingRecorder) unknownStateEvents() []events.Event { + out := make([]events.Event, 0, len(c.events)) + for _, e := range c.events { + if e.Type == events.SessionUnknownState { + out = append(out, e) + } + } + return out +} + +// newUnknownStateSession creates a session bead carrying the given unrecognized +// state and returns the store plus bead ID. Reloading the bead per call models +// how the reconciler re-projects each session from the store every tick, so the +// durable throttle markers stamped by the diagnostic are read back next tick. +func newUnknownStateSession(t *testing.T, name, state string) (beads.Store, string) { + t.Helper() + store := beads.NewMemStore() + b, err := store.Create(beads.Bead{ + Title: name, + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": name, + "state": state, + }, + }) + if err != nil { + t.Fatalf("creating session bead: %v", err) + } + return store, b.ID +} + +// The unknown-state diagnostic must fire once on first sight, stay silent while +// the same unrecognized state persists (no per-tick #2389 spam), and re-fire +// exactly once with escalated=true after the bead has sat unrecognized past the +// threshold — the durable markers making the throttle survive reconciler +// restarts (#2085) and the first-seen clock queryable (#1497). +func TestEmitSessionUnknownStateDiagnostic_ThrottlesAndEscalates(t *testing.T) { + if sample, ok := events.LookupPayload(events.SessionUnknownState); !ok { + t.Fatal("no payload registered for session.unknown_state") + } else if _, typed := sample.(api.SessionUnknownStatePayload); !typed { + t.Fatalf("registered session.unknown_state payload = %T, want api.SessionUnknownStatePayload", sample) + } + + store, id := newUnknownStateSession(t, "worker-x", "quantum-limbo") + clk := &clock.Fake{Time: time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC)} + rec := &capturingRecorder{} + var stderr bytes.Buffer + + call := func() { + fresh, err := store.Get(id) + if err != nil { + t.Fatalf("Get session bead: %v", err) + } + emitSessionUnknownStateDiagnostic(store, &fresh, sessionpkg.InfoFromPersistedBead(fresh), rec, clk, &stderr) + } + + call() // first sight + if got := rec.unknownStateEvents(); len(got) != 1 { + t.Fatalf("first-sight events = %d, want 1; events: %+v", len(got), rec.events) + } + first := rec.unknownStateEvents()[0] + if first.Subject != id || first.SessionID != id { + t.Fatalf("event subject/session = %q/%q, want %q", first.Subject, first.SessionID, id) + } + var payload api.SessionUnknownStatePayload + if err := json.Unmarshal(first.Payload, &payload); err != nil { + t.Fatalf("decoding payload: %v", err) + } + if payload.State != "quantum-limbo" || payload.SessionName != "worker-x" || payload.Escalated { + t.Fatalf("payload = %+v, want state=quantum-limbo name=worker-x escalated=false", payload) + } + firstLogLines := strings.Count(stderr.String(), "unknown state") + if firstLogLines != 1 { + t.Fatalf("stderr unknown-state lines = %d after first sight, want 1: %q", firstLogLines, stderr.String()) + } + + call() // same state, before threshold: throttled + if got := rec.unknownStateEvents(); len(got) != 1 { + t.Fatalf("post-throttle events = %d, want 1 (no re-emit while state persists)", len(got)) + } + if lines := strings.Count(stderr.String(), "unknown state"); lines != 1 { + t.Fatalf("stderr unknown-state lines = %d, want 1 (no per-tick spam)", lines) + } + + clk.Advance(unknownStateEscalationAge + time.Minute) + call() // now past threshold: escalate once + esc := rec.unknownStateEvents() + if len(esc) != 2 { + t.Fatalf("post-threshold events = %d, want 2", len(esc)) + } + if err := json.Unmarshal(esc[1].Payload, &payload); err != nil { + t.Fatalf("decoding escalation payload: %v", err) + } + if !payload.Escalated { + t.Fatalf("escalation payload escalated = false, want true") + } + + clk.Advance(time.Hour) + call() // escalation is once-only + if got := rec.unknownStateEvents(); len(got) != 2 { + t.Fatalf("post-escalation events = %d, want 2 (escalation fires once)", len(got)) + } +} + +// A change to a *different* unrecognized state resets the first-seen clock and +// re-emits (escalated=false), clearing any prior escalation guard. +func TestEmitSessionUnknownStateDiagnostic_StateChangeReemits(t *testing.T) { + store, id := newUnknownStateSession(t, "worker-y", "state-a") + clk := &clock.Fake{Time: time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC)} + rec := &capturingRecorder{} + var stderr bytes.Buffer + + call := func() { + fresh, err := store.Get(id) + if err != nil { + t.Fatalf("Get session bead: %v", err) + } + emitSessionUnknownStateDiagnostic(store, &fresh, sessionpkg.InfoFromPersistedBead(fresh), rec, clk, &stderr) + } + + call() // first sight of state-a + // Escalate state-a so the escalation guard is set. + clk.Advance(unknownStateEscalationAge + time.Minute) + call() + if got := rec.unknownStateEvents(); len(got) != 2 { + t.Fatalf("state-a events = %d, want 2 (first sight + escalation)", len(got)) + } + + // A different unrecognized state is a fresh transition: re-emit, not throttle. + if err := store.SetMetadata(id, "state", "state-b"); err != nil { + t.Fatalf("SetMetadata: %v", err) + } + call() + got := rec.unknownStateEvents() + if len(got) != 3 { + t.Fatalf("post-change events = %d, want 3", len(got)) + } + var payload api.SessionUnknownStatePayload + if err := json.Unmarshal(got[2].Payload, &payload); err != nil { + t.Fatalf("decoding payload: %v", err) + } + if payload.State != "state-b" || payload.Escalated { + t.Fatalf("payload = %+v, want state=state-b escalated=false (fresh window)", payload) + } + + // The escalation guard for the previous state must have been cleared. + fresh, err := store.Get(id) + if err != nil { + t.Fatalf("Get: %v", err) + } + if v := strings.TrimSpace(fresh.Metadata[unknownStateEscalatedKey]); v != "" { + t.Fatalf("escalation marker = %q, want cleared after state change", v) + } + if fresh.Metadata[unknownStateValueKey] != "state-b" { + t.Fatalf("value marker = %q, want state-b", fresh.Metadata[unknownStateValueKey]) + } +} + +// A metadata state that is a known value wrapped in whitespace (e.g. " active ") +// is classified as UNKNOWN because isKnownStateInfo keys off the raw, untrimmed +// value. The diagnostic must then report that raw value verbatim — in the event +// payload, the operator message, and the durable value marker — so operators see +// the actual invalid metadata rather than a trimmed, known-looking "active" that +// hides why the bead was skipped. Regression guard for +// SessionUnknownStatePayload.State's documented "raw ... value" contract. +func TestEmitSessionUnknownStateDiagnostic_ReportsRawWhitespaceState(t *testing.T) { + const rawState = " active " + if isKnownStateInfo(sessionpkg.Info{MetadataState: rawState}) { + t.Fatalf("precondition: %q must classify as unknown (raw, untrimmed)", rawState) + } + + store, id := newUnknownStateSession(t, "worker-ws", rawState) + clk := &clock.Fake{Time: time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC)} + rec := &capturingRecorder{} + var stderr bytes.Buffer + + fresh, err := store.Get(id) + if err != nil { + t.Fatalf("Get session bead: %v", err) + } + emitSessionUnknownStateDiagnostic(store, &fresh, sessionpkg.InfoFromPersistedBead(fresh), rec, clk, &stderr) + + got := rec.unknownStateEvents() + if len(got) != 1 { + t.Fatalf("first-sight events = %d, want 1", len(got)) + } + var payload api.SessionUnknownStatePayload + if err := json.Unmarshal(got[0].Payload, &payload); err != nil { + t.Fatalf("decoding payload: %v", err) + } + if payload.State != rawState { + t.Fatalf("payload.State = %q, want raw %q (untrimmed)", payload.State, rawState) + } + if !strings.Contains(got[0].Message, rawState) { + t.Fatalf("event message %q, want it to contain the raw state %q", got[0].Message, rawState) + } + + // The durable value marker must also store the raw value so the throttle + // compares like-for-like against info.MetadataState on subsequent ticks. + reloaded, err := store.Get(id) + if err != nil { + t.Fatalf("Get: %v", err) + } + if marker := reloaded.Metadata[unknownStateValueKey]; marker != rawState { + t.Fatalf("value marker = %q, want raw %q", marker, rawState) + } +} + +// After a session recovers to a known state, the reconciler clears the +// unknown-state throttle markers so that a later recurrence of the SAME +// unrecognized value re-emits instead of being silently suppressed as "same +// state as last tick". Without clearSessionUnknownStateMarkers the recurrence +// stays silent because the durable first-seen/value markers still match. +func TestClearSessionUnknownStateMarkers_RecurrenceReemitsAfterRecovery(t *testing.T) { + store, id := newUnknownStateSession(t, "worker-z", "quantum-limbo") + clk := &clock.Fake{Time: time.Date(2026, 5, 23, 12, 0, 0, 0, time.UTC)} + rec := &capturingRecorder{} + var stderr bytes.Buffer + + emit := func() { + fresh, err := store.Get(id) + if err != nil { + t.Fatalf("Get: %v", err) + } + emitSessionUnknownStateDiagnostic(store, &fresh, sessionpkg.InfoFromPersistedBead(fresh), rec, clk, &stderr) + } + + emit() // first sight of quantum-limbo + if got := rec.unknownStateEvents(); len(got) != 1 { + t.Fatalf("first-sight events = %d, want 1", len(got)) + } + + // The session recovers to a known state; the reconciler clears the markers. + if err := store.SetMetadata(id, "state", "active"); err != nil { + t.Fatalf("SetMetadata(active): %v", err) + } + fresh, err := store.Get(id) + if err != nil { + t.Fatalf("Get: %v", err) + } + if !isKnownStateInfo(sessionpkg.InfoFromPersistedBead(fresh)) { + t.Fatal("expected \"active\" to be a known state for this test") + } + clearSessionUnknownStateMarkers(store, &fresh, &stderr) + + // The durable markers must be gone. + reloaded, err := store.Get(id) + if err != nil { + t.Fatalf("Get: %v", err) + } + for _, key := range []string{unknownStateFirstSeenKey, unknownStateValueKey, unknownStateEscalatedKey} { + if v := strings.TrimSpace(reloaded.Metadata[key]); v != "" { + t.Fatalf("marker %s = %q, want cleared after recovery to a known state", key, v) + } + } + + // The same unrecognized value recurs later. It must re-emit (fresh + // first-sight), not stay throttled behind the now-cleared markers. + if err := store.SetMetadata(id, "state", "quantum-limbo"); err != nil { + t.Fatalf("SetMetadata(recurrence): %v", err) + } + clk.Advance(time.Minute) + emit() + if got := rec.unknownStateEvents(); len(got) != 2 { + t.Fatalf("recurrence events = %d, want 2 (recurrence re-emits after marker clear)", len(got)) + } +} + // TestReconcileSessionBeads_PoolSlotWithStrandedWorkEmitsDiagnostic // covers issue #1424: when a pool-managed session is observed // asleep + not-alive AND still has open in-progress work assigned, the diff --git a/docs/reference/schema/openapi.json b/docs/reference/schema/openapi.json index 3076be27f4..1f15a4bb4b 100644 --- a/docs/reference/schema/openapi.json +++ b/docs/reference/schema/openapi.json @@ -2305,6 +2305,9 @@ { "$ref": "#/components/schemas/SessionSubmitSucceededPayload" }, + { + "$ref": "#/components/schemas/SessionUnknownStatePayload" + }, { "$ref": "#/components/schemas/StoreDiskCriticalPayload" }, @@ -7218,6 +7221,37 @@ ], "type": "object" }, + "SessionUnknownStatePayload": { + "additionalProperties": false, + "properties": { + "escalated": { + "description": "False on the first-sight emission; true when re-emitted after the bead has sat unrecognized past the escalation threshold.", + "type": "boolean" + }, + "first_seen": { + "description": "RFC3339 timestamp the reconciler first observed this unrecognized state; the escalation clock counts from here.", + "type": "string" + }, + "session_id": { + "description": "Canonical session bead ID for the unrecognized-state session (also the envelope Subject).", + "type": "string" + }, + "session_name": { + "description": "Runtime session name from the session bead metadata, when set.", + "type": "string" + }, + "state": { + "description": "The raw, unrecognized metadata state value the reconciler skipped.", + "type": "string" + } + }, + "required": [ + "session_id", + "state", + "escalated" + ], + "type": "object" + }, "SlingInputBody": { "additionalProperties": false, "properties": { @@ -8318,6 +8352,7 @@ "session.stranded": "#/components/schemas/TypedEventStreamEnvelopeSessionStranded", "session.suspended": "#/components/schemas/TypedEventStreamEnvelopeSessionSuspended", "session.undrained": "#/components/schemas/TypedEventStreamEnvelopeSessionUndrained", + "session.unknown_state": "#/components/schemas/TypedEventStreamEnvelopeSessionUnknownState", "session.updated": "#/components/schemas/TypedEventStreamEnvelopeSessionUpdated", "session.woke": "#/components/schemas/TypedEventStreamEnvelopeSessionWoke", "session.work_query_failed": "#/components/schemas/TypedEventStreamEnvelopeSessionWorkQueryFailed", @@ -8521,6 +8556,9 @@ { "$ref": "#/components/schemas/TypedEventStreamEnvelopeSessionUndrained" }, + { + "$ref": "#/components/schemas/TypedEventStreamEnvelopeSessionUnknownState" + }, { "$ref": "#/components/schemas/TypedEventStreamEnvelopeSessionUpdated" }, @@ -9419,6 +9457,7 @@ "session.updated", "session.drain_acked_with_assigned_work", "session.stranded", + "session.unknown_state", "session.reset_stalled", "session.work_query_failed", "session.cold_start_timeout", @@ -11895,6 +11934,57 @@ "title": "TypedEventStreamEnvelope session.undrained", "type": "object" }, + "TypedEventStreamEnvelopeSessionUnknownState": { + "additionalProperties": false, + "properties": { + "actor": { + "type": "string" + }, + "message": { + "type": "string" + }, + "payload": { + "$ref": "#/components/schemas/SessionUnknownStatePayload" + }, + "run_id": { + "type": "string" + }, + "seq": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "session_id": { + "type": "string" + }, + "step_id": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "ts": { + "format": "date-time", + "type": "string" + }, + "type": { + "const": "session.unknown_state", + "type": "string" + }, + "workflow": { + "$ref": "#/components/schemas/WorkflowEventProjection" + } + }, + "required": [ + "seq", + "type", + "ts", + "actor", + "payload" + ], + "title": "TypedEventStreamEnvelope session.unknown_state", + "type": "object" + }, "TypedEventStreamEnvelopeSessionUpdated": { "additionalProperties": false, "properties": { @@ -12472,6 +12562,7 @@ "session.stranded": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionStranded", "session.suspended": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionSuspended", "session.undrained": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionUndrained", + "session.unknown_state": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionUnknownState", "session.updated": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionUpdated", "session.woke": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionWoke", "session.work_query_failed": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionWorkQueryFailed", @@ -12675,6 +12766,9 @@ { "$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionUndrained" }, + { + "$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionUnknownState" + }, { "$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionUpdated" }, @@ -13640,6 +13734,7 @@ "session.updated", "session.drain_acked_with_assigned_work", "session.stranded", + "session.unknown_state", "session.reset_stalled", "session.work_query_failed", "session.cold_start_timeout", @@ -16305,6 +16400,61 @@ "title": "TypedTaggedEventStreamEnvelope session.undrained", "type": "object" }, + "TypedTaggedEventStreamEnvelopeSessionUnknownState": { + "additionalProperties": false, + "properties": { + "actor": { + "type": "string" + }, + "city": { + "type": "string" + }, + "message": { + "type": "string" + }, + "payload": { + "$ref": "#/components/schemas/SessionUnknownStatePayload" + }, + "run_id": { + "type": "string" + }, + "seq": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "session_id": { + "type": "string" + }, + "step_id": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "ts": { + "format": "date-time", + "type": "string" + }, + "type": { + "const": "session.unknown_state", + "type": "string" + }, + "workflow": { + "$ref": "#/components/schemas/WorkflowEventProjection" + } + }, + "required": [ + "seq", + "type", + "ts", + "actor", + "payload", + "city" + ], + "title": "TypedTaggedEventStreamEnvelope session.unknown_state", + "type": "object" + }, "TypedTaggedEventStreamEnvelopeSessionUpdated": { "additionalProperties": false, "properties": { diff --git a/docs/reference/schema/openapi.txt b/docs/reference/schema/openapi.txt index 3076be27f4..1f15a4bb4b 100644 --- a/docs/reference/schema/openapi.txt +++ b/docs/reference/schema/openapi.txt @@ -2305,6 +2305,9 @@ { "$ref": "#/components/schemas/SessionSubmitSucceededPayload" }, + { + "$ref": "#/components/schemas/SessionUnknownStatePayload" + }, { "$ref": "#/components/schemas/StoreDiskCriticalPayload" }, @@ -7218,6 +7221,37 @@ ], "type": "object" }, + "SessionUnknownStatePayload": { + "additionalProperties": false, + "properties": { + "escalated": { + "description": "False on the first-sight emission; true when re-emitted after the bead has sat unrecognized past the escalation threshold.", + "type": "boolean" + }, + "first_seen": { + "description": "RFC3339 timestamp the reconciler first observed this unrecognized state; the escalation clock counts from here.", + "type": "string" + }, + "session_id": { + "description": "Canonical session bead ID for the unrecognized-state session (also the envelope Subject).", + "type": "string" + }, + "session_name": { + "description": "Runtime session name from the session bead metadata, when set.", + "type": "string" + }, + "state": { + "description": "The raw, unrecognized metadata state value the reconciler skipped.", + "type": "string" + } + }, + "required": [ + "session_id", + "state", + "escalated" + ], + "type": "object" + }, "SlingInputBody": { "additionalProperties": false, "properties": { @@ -8318,6 +8352,7 @@ "session.stranded": "#/components/schemas/TypedEventStreamEnvelopeSessionStranded", "session.suspended": "#/components/schemas/TypedEventStreamEnvelopeSessionSuspended", "session.undrained": "#/components/schemas/TypedEventStreamEnvelopeSessionUndrained", + "session.unknown_state": "#/components/schemas/TypedEventStreamEnvelopeSessionUnknownState", "session.updated": "#/components/schemas/TypedEventStreamEnvelopeSessionUpdated", "session.woke": "#/components/schemas/TypedEventStreamEnvelopeSessionWoke", "session.work_query_failed": "#/components/schemas/TypedEventStreamEnvelopeSessionWorkQueryFailed", @@ -8521,6 +8556,9 @@ { "$ref": "#/components/schemas/TypedEventStreamEnvelopeSessionUndrained" }, + { + "$ref": "#/components/schemas/TypedEventStreamEnvelopeSessionUnknownState" + }, { "$ref": "#/components/schemas/TypedEventStreamEnvelopeSessionUpdated" }, @@ -9419,6 +9457,7 @@ "session.updated", "session.drain_acked_with_assigned_work", "session.stranded", + "session.unknown_state", "session.reset_stalled", "session.work_query_failed", "session.cold_start_timeout", @@ -11895,6 +11934,57 @@ "title": "TypedEventStreamEnvelope session.undrained", "type": "object" }, + "TypedEventStreamEnvelopeSessionUnknownState": { + "additionalProperties": false, + "properties": { + "actor": { + "type": "string" + }, + "message": { + "type": "string" + }, + "payload": { + "$ref": "#/components/schemas/SessionUnknownStatePayload" + }, + "run_id": { + "type": "string" + }, + "seq": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "session_id": { + "type": "string" + }, + "step_id": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "ts": { + "format": "date-time", + "type": "string" + }, + "type": { + "const": "session.unknown_state", + "type": "string" + }, + "workflow": { + "$ref": "#/components/schemas/WorkflowEventProjection" + } + }, + "required": [ + "seq", + "type", + "ts", + "actor", + "payload" + ], + "title": "TypedEventStreamEnvelope session.unknown_state", + "type": "object" + }, "TypedEventStreamEnvelopeSessionUpdated": { "additionalProperties": false, "properties": { @@ -12472,6 +12562,7 @@ "session.stranded": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionStranded", "session.suspended": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionSuspended", "session.undrained": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionUndrained", + "session.unknown_state": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionUnknownState", "session.updated": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionUpdated", "session.woke": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionWoke", "session.work_query_failed": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionWorkQueryFailed", @@ -12675,6 +12766,9 @@ { "$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionUndrained" }, + { + "$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionUnknownState" + }, { "$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionUpdated" }, @@ -13640,6 +13734,7 @@ "session.updated", "session.drain_acked_with_assigned_work", "session.stranded", + "session.unknown_state", "session.reset_stalled", "session.work_query_failed", "session.cold_start_timeout", @@ -16305,6 +16400,61 @@ "title": "TypedTaggedEventStreamEnvelope session.undrained", "type": "object" }, + "TypedTaggedEventStreamEnvelopeSessionUnknownState": { + "additionalProperties": false, + "properties": { + "actor": { + "type": "string" + }, + "city": { + "type": "string" + }, + "message": { + "type": "string" + }, + "payload": { + "$ref": "#/components/schemas/SessionUnknownStatePayload" + }, + "run_id": { + "type": "string" + }, + "seq": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "session_id": { + "type": "string" + }, + "step_id": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "ts": { + "format": "date-time", + "type": "string" + }, + "type": { + "const": "session.unknown_state", + "type": "string" + }, + "workflow": { + "$ref": "#/components/schemas/WorkflowEventProjection" + } + }, + "required": [ + "seq", + "type", + "ts", + "actor", + "payload", + "city" + ], + "title": "TypedTaggedEventStreamEnvelope session.unknown_state", + "type": "object" + }, "TypedTaggedEventStreamEnvelopeSessionUpdated": { "additionalProperties": false, "properties": { diff --git a/internal/api/event_payloads.go b/internal/api/event_payloads.go index 8c92b711ab..bed0f264db 100644 --- a/internal/api/event_payloads.go +++ b/internal/api/event_payloads.go @@ -533,6 +533,41 @@ func BeadDeadAssigneeReopenedPayloadJSON(beadID, deadAssignee, routedTo string) return b } +// SessionUnknownStatePayload carries the machine-readable context for a +// session.unknown_state event: a session bead whose metadata state the +// reconciler does not recognize and therefore skips (forward-compatible +// rollback). The envelope Message renders the same facts as operator text; +// this payload is the machine contract so subscribers can correlate the stuck +// bead, compute how long it has been unrecognized, and distinguish the +// first-sight emission from the past-threshold escalation. +type SessionUnknownStatePayload struct { + SessionID string `json:"session_id" doc:"Canonical session bead ID for the unrecognized-state session (also the envelope Subject)."` + SessionName string `json:"session_name,omitempty" doc:"Runtime session name from the session bead metadata, when set."` + State string `json:"state" doc:"The raw, unrecognized metadata state value the reconciler skipped."` + FirstSeen string `json:"first_seen,omitempty" doc:"RFC3339 timestamp the reconciler first observed this unrecognized state; the escalation clock counts from here."` + Escalated bool `json:"escalated" doc:"False on the first-sight emission; true when re-emitted after the bead has sat unrecognized past the escalation threshold."` +} + +// IsEventPayload marks SessionUnknownStatePayload as an events.Payload variant. +func (SessionUnknownStatePayload) IsEventPayload() {} + +// SessionUnknownStatePayloadJSON builds the JSON wire form for attachment to an +// events.Event.Payload field. SessionName and FirstSeen are emitted only when +// set. +func SessionUnknownStatePayloadJSON(sessionID, sessionName, state string, firstSeen time.Time, escalated bool) json.RawMessage { + p := SessionUnknownStatePayload{ + SessionID: sessionID, + SessionName: sessionName, + State: state, + Escalated: escalated, + } + if !firstSeen.IsZero() { + p.FirstSeen = firstSeen.UTC().Format(time.RFC3339) + } + b, _ := json.Marshal(p) + return b +} + func init() { // mail.* — all seven types share one payload shape. events.RegisterPayload(events.MailSent, MailEventPayload{}) @@ -568,6 +603,7 @@ func init() { events.RegisterPayload(events.SessionUpdated, events.NoPayload{}) events.RegisterPayload(events.SessionDrainAckedWithAssignedWork, SessionDrainAckedWithAssignedWorkPayload{}) events.RegisterPayload(events.SessionStranded, SessionStrandedPayload{}) + events.RegisterPayload(events.SessionUnknownState, SessionUnknownStatePayload{}) events.RegisterPayload(events.SessionResetStalled, events.SessionResetStalledPayload{}) events.RegisterPayload(events.SessionWorkQueryFailed, SessionLifecyclePayload{}) events.RegisterPayload(events.SessionColdStartTimeout, events.NoPayload{}) diff --git a/internal/api/genclient/client_gen.go b/internal/api/genclient/client_gen.go index 0f5a59ee4f..27a9fe1331 100644 --- a/internal/api/genclient/client_gen.go +++ b/internal/api/genclient/client_gen.go @@ -2988,6 +2988,24 @@ type SessionTranscriptGetResponse struct { Turns *[]OutputTurn `json:"turns,omitempty"` } +// SessionUnknownStatePayload defines model for SessionUnknownStatePayload. +type SessionUnknownStatePayload struct { + // Escalated False on the first-sight emission; true when re-emitted after the bead has sat unrecognized past the escalation threshold. + Escalated bool `json:"escalated"` + + // FirstSeen RFC3339 timestamp the reconciler first observed this unrecognized state; the escalation clock counts from here. + FirstSeen *string `json:"first_seen,omitempty"` + + // SessionId Canonical session bead ID for the unrecognized-state session (also the envelope Subject). + SessionId string `json:"session_id"` + + // SessionName Runtime session name from the session bead metadata, when set. + SessionName *string `json:"session_name,omitempty"` + + // State The raw, unrecognized metadata state value the reconciler skipped. + State string `json:"state"` +} + // SlingInputBody defines model for SlingInputBody. type SlingInputBody struct { // AttachedBeadId Bead ID to attach a formula to. @@ -4411,6 +4429,21 @@ type TypedEventStreamEnvelopeSessionUndrained struct { Workflow *WorkflowEventProjection `json:"workflow,omitempty"` } +// TypedEventStreamEnvelopeSessionUnknownState defines model for TypedEventStreamEnvelopeSessionUnknownState. +type TypedEventStreamEnvelopeSessionUnknownState struct { + Actor string `json:"actor"` + Message *string `json:"message,omitempty"` + Payload SessionUnknownStatePayload `json:"payload"` + RunId *string `json:"run_id,omitempty"` + Seq int64 `json:"seq"` + SessionId *string `json:"session_id,omitempty"` + StepId *string `json:"step_id,omitempty"` + Subject *string `json:"subject,omitempty"` + Ts time.Time `json:"ts"` + Type string `json:"type"` + Workflow *WorkflowEventProjection `json:"workflow,omitempty"` +} + // TypedEventStreamEnvelopeSessionUpdated defines model for TypedEventStreamEnvelopeSessionUpdated. type TypedEventStreamEnvelopeSessionUpdated struct { Actor string `json:"actor"` @@ -5590,6 +5623,22 @@ type TypedTaggedEventStreamEnvelopeSessionUndrained struct { Workflow *WorkflowEventProjection `json:"workflow,omitempty"` } +// TypedTaggedEventStreamEnvelopeSessionUnknownState defines model for TypedTaggedEventStreamEnvelopeSessionUnknownState. +type TypedTaggedEventStreamEnvelopeSessionUnknownState struct { + Actor string `json:"actor"` + City string `json:"city"` + Message *string `json:"message,omitempty"` + Payload SessionUnknownStatePayload `json:"payload"` + RunId *string `json:"run_id,omitempty"` + Seq int64 `json:"seq"` + SessionId *string `json:"session_id,omitempty"` + StepId *string `json:"step_id,omitempty"` + Subject *string `json:"subject,omitempty"` + Ts time.Time `json:"ts"` + Type string `json:"type"` + Workflow *WorkflowEventProjection `json:"workflow,omitempty"` +} + // TypedTaggedEventStreamEnvelopeSessionUpdated defines model for TypedTaggedEventStreamEnvelopeSessionUpdated. type TypedTaggedEventStreamEnvelopeSessionUpdated struct { Actor string `json:"actor"` @@ -7851,6 +7900,32 @@ func (t *EventPayload) MergeSessionSubmitSucceededPayload(v SessionSubmitSucceed return err } +// AsSessionUnknownStatePayload returns the union data inside the EventPayload as a SessionUnknownStatePayload +func (t EventPayload) AsSessionUnknownStatePayload() (SessionUnknownStatePayload, error) { + var body SessionUnknownStatePayload + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromSessionUnknownStatePayload overwrites any union data inside the EventPayload as the provided SessionUnknownStatePayload +func (t *EventPayload) FromSessionUnknownStatePayload(v SessionUnknownStatePayload) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeSessionUnknownStatePayload performs a merge with any union data inside the EventPayload, using the provided SessionUnknownStatePayload +func (t *EventPayload) MergeSessionUnknownStatePayload(v SessionUnknownStatePayload) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + // AsStoreDiskCriticalPayload returns the union data inside the EventPayload as a StoreDiskCriticalPayload func (t EventPayload) AsStoreDiskCriticalPayload() (StoreDiskCriticalPayload, error) { var body StoreDiskCriticalPayload @@ -10025,6 +10100,34 @@ func (t *TypedEventStreamEnvelope) MergeTypedEventStreamEnvelopeSessionUndrained return err } +// AsTypedEventStreamEnvelopeSessionUnknownState returns the union data inside the TypedEventStreamEnvelope as a TypedEventStreamEnvelopeSessionUnknownState +func (t TypedEventStreamEnvelope) AsTypedEventStreamEnvelopeSessionUnknownState() (TypedEventStreamEnvelopeSessionUnknownState, error) { + var body TypedEventStreamEnvelopeSessionUnknownState + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTypedEventStreamEnvelopeSessionUnknownState overwrites any union data inside the TypedEventStreamEnvelope as the provided TypedEventStreamEnvelopeSessionUnknownState +func (t *TypedEventStreamEnvelope) FromTypedEventStreamEnvelopeSessionUnknownState(v TypedEventStreamEnvelopeSessionUnknownState) error { + v.Type = "session.unknown_state" + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTypedEventStreamEnvelopeSessionUnknownState performs a merge with any union data inside the TypedEventStreamEnvelope, using the provided TypedEventStreamEnvelopeSessionUnknownState +func (t *TypedEventStreamEnvelope) MergeTypedEventStreamEnvelopeSessionUnknownState(v TypedEventStreamEnvelopeSessionUnknownState) error { + v.Type = "session.unknown_state" + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + // AsTypedEventStreamEnvelopeSessionUpdated returns the union data inside the TypedEventStreamEnvelope as a TypedEventStreamEnvelopeSessionUpdated func (t TypedEventStreamEnvelope) AsTypedEventStreamEnvelopeSessionUpdated() (TypedEventStreamEnvelopeSessionUpdated, error) { var body TypedEventStreamEnvelopeSessionUpdated @@ -10475,6 +10578,8 @@ func (t TypedEventStreamEnvelope) ValueByDiscriminator() (interface{}, error) { return t.AsTypedEventStreamEnvelopeSessionSuspended() case "session.undrained": return t.AsTypedEventStreamEnvelopeSessionUndrained() + case "session.unknown_state": + return t.AsTypedEventStreamEnvelopeSessionUnknownState() case "session.updated": return t.AsTypedEventStreamEnvelopeSessionUpdated() case "session.woke": @@ -12274,6 +12379,34 @@ func (t *TypedTaggedEventStreamEnvelope) MergeTypedTaggedEventStreamEnvelopeSess return err } +// AsTypedTaggedEventStreamEnvelopeSessionUnknownState returns the union data inside the TypedTaggedEventStreamEnvelope as a TypedTaggedEventStreamEnvelopeSessionUnknownState +func (t TypedTaggedEventStreamEnvelope) AsTypedTaggedEventStreamEnvelopeSessionUnknownState() (TypedTaggedEventStreamEnvelopeSessionUnknownState, error) { + var body TypedTaggedEventStreamEnvelopeSessionUnknownState + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTypedTaggedEventStreamEnvelopeSessionUnknownState overwrites any union data inside the TypedTaggedEventStreamEnvelope as the provided TypedTaggedEventStreamEnvelopeSessionUnknownState +func (t *TypedTaggedEventStreamEnvelope) FromTypedTaggedEventStreamEnvelopeSessionUnknownState(v TypedTaggedEventStreamEnvelopeSessionUnknownState) error { + v.Type = "session.unknown_state" + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTypedTaggedEventStreamEnvelopeSessionUnknownState performs a merge with any union data inside the TypedTaggedEventStreamEnvelope, using the provided TypedTaggedEventStreamEnvelopeSessionUnknownState +func (t *TypedTaggedEventStreamEnvelope) MergeTypedTaggedEventStreamEnvelopeSessionUnknownState(v TypedTaggedEventStreamEnvelopeSessionUnknownState) error { + v.Type = "session.unknown_state" + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + // AsTypedTaggedEventStreamEnvelopeSessionUpdated returns the union data inside the TypedTaggedEventStreamEnvelope as a TypedTaggedEventStreamEnvelopeSessionUpdated func (t TypedTaggedEventStreamEnvelope) AsTypedTaggedEventStreamEnvelopeSessionUpdated() (TypedTaggedEventStreamEnvelopeSessionUpdated, error) { var body TypedTaggedEventStreamEnvelopeSessionUpdated @@ -12724,6 +12857,8 @@ func (t TypedTaggedEventStreamEnvelope) ValueByDiscriminator() (interface{}, err return t.AsTypedTaggedEventStreamEnvelopeSessionSuspended() case "session.undrained": return t.AsTypedTaggedEventStreamEnvelopeSessionUndrained() + case "session.unknown_state": + return t.AsTypedTaggedEventStreamEnvelopeSessionUnknownState() case "session.updated": return t.AsTypedTaggedEventStreamEnvelopeSessionUpdated() case "session.woke": diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 3076be27f4..1f15a4bb4b 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -2305,6 +2305,9 @@ { "$ref": "#/components/schemas/SessionSubmitSucceededPayload" }, + { + "$ref": "#/components/schemas/SessionUnknownStatePayload" + }, { "$ref": "#/components/schemas/StoreDiskCriticalPayload" }, @@ -7218,6 +7221,37 @@ ], "type": "object" }, + "SessionUnknownStatePayload": { + "additionalProperties": false, + "properties": { + "escalated": { + "description": "False on the first-sight emission; true when re-emitted after the bead has sat unrecognized past the escalation threshold.", + "type": "boolean" + }, + "first_seen": { + "description": "RFC3339 timestamp the reconciler first observed this unrecognized state; the escalation clock counts from here.", + "type": "string" + }, + "session_id": { + "description": "Canonical session bead ID for the unrecognized-state session (also the envelope Subject).", + "type": "string" + }, + "session_name": { + "description": "Runtime session name from the session bead metadata, when set.", + "type": "string" + }, + "state": { + "description": "The raw, unrecognized metadata state value the reconciler skipped.", + "type": "string" + } + }, + "required": [ + "session_id", + "state", + "escalated" + ], + "type": "object" + }, "SlingInputBody": { "additionalProperties": false, "properties": { @@ -8318,6 +8352,7 @@ "session.stranded": "#/components/schemas/TypedEventStreamEnvelopeSessionStranded", "session.suspended": "#/components/schemas/TypedEventStreamEnvelopeSessionSuspended", "session.undrained": "#/components/schemas/TypedEventStreamEnvelopeSessionUndrained", + "session.unknown_state": "#/components/schemas/TypedEventStreamEnvelopeSessionUnknownState", "session.updated": "#/components/schemas/TypedEventStreamEnvelopeSessionUpdated", "session.woke": "#/components/schemas/TypedEventStreamEnvelopeSessionWoke", "session.work_query_failed": "#/components/schemas/TypedEventStreamEnvelopeSessionWorkQueryFailed", @@ -8521,6 +8556,9 @@ { "$ref": "#/components/schemas/TypedEventStreamEnvelopeSessionUndrained" }, + { + "$ref": "#/components/schemas/TypedEventStreamEnvelopeSessionUnknownState" + }, { "$ref": "#/components/schemas/TypedEventStreamEnvelopeSessionUpdated" }, @@ -9419,6 +9457,7 @@ "session.updated", "session.drain_acked_with_assigned_work", "session.stranded", + "session.unknown_state", "session.reset_stalled", "session.work_query_failed", "session.cold_start_timeout", @@ -11895,6 +11934,57 @@ "title": "TypedEventStreamEnvelope session.undrained", "type": "object" }, + "TypedEventStreamEnvelopeSessionUnknownState": { + "additionalProperties": false, + "properties": { + "actor": { + "type": "string" + }, + "message": { + "type": "string" + }, + "payload": { + "$ref": "#/components/schemas/SessionUnknownStatePayload" + }, + "run_id": { + "type": "string" + }, + "seq": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "session_id": { + "type": "string" + }, + "step_id": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "ts": { + "format": "date-time", + "type": "string" + }, + "type": { + "const": "session.unknown_state", + "type": "string" + }, + "workflow": { + "$ref": "#/components/schemas/WorkflowEventProjection" + } + }, + "required": [ + "seq", + "type", + "ts", + "actor", + "payload" + ], + "title": "TypedEventStreamEnvelope session.unknown_state", + "type": "object" + }, "TypedEventStreamEnvelopeSessionUpdated": { "additionalProperties": false, "properties": { @@ -12472,6 +12562,7 @@ "session.stranded": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionStranded", "session.suspended": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionSuspended", "session.undrained": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionUndrained", + "session.unknown_state": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionUnknownState", "session.updated": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionUpdated", "session.woke": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionWoke", "session.work_query_failed": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionWorkQueryFailed", @@ -12675,6 +12766,9 @@ { "$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionUndrained" }, + { + "$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionUnknownState" + }, { "$ref": "#/components/schemas/TypedTaggedEventStreamEnvelopeSessionUpdated" }, @@ -13640,6 +13734,7 @@ "session.updated", "session.drain_acked_with_assigned_work", "session.stranded", + "session.unknown_state", "session.reset_stalled", "session.work_query_failed", "session.cold_start_timeout", @@ -16305,6 +16400,61 @@ "title": "TypedTaggedEventStreamEnvelope session.undrained", "type": "object" }, + "TypedTaggedEventStreamEnvelopeSessionUnknownState": { + "additionalProperties": false, + "properties": { + "actor": { + "type": "string" + }, + "city": { + "type": "string" + }, + "message": { + "type": "string" + }, + "payload": { + "$ref": "#/components/schemas/SessionUnknownStatePayload" + }, + "run_id": { + "type": "string" + }, + "seq": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "session_id": { + "type": "string" + }, + "step_id": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "ts": { + "format": "date-time", + "type": "string" + }, + "type": { + "const": "session.unknown_state", + "type": "string" + }, + "workflow": { + "$ref": "#/components/schemas/WorkflowEventProjection" + } + }, + "required": [ + "seq", + "type", + "ts", + "actor", + "payload", + "city" + ], + "title": "TypedTaggedEventStreamEnvelope session.unknown_state", + "type": "object" + }, "TypedTaggedEventStreamEnvelopeSessionUpdated": { "additionalProperties": false, "properties": { diff --git a/internal/events/events.go b/internal/events/events.go index ac37216811..3c9328695e 100644 --- a/internal/events/events.go +++ b/internal/events/events.go @@ -75,6 +75,15 @@ const ( // the reconciler-detected leak so pack-level subscribers can decide // whether to clear-assignee-and-respawn or escalate. SessionStranded = "session.stranded" + // SessionUnknownState fires when the reconciler observes a session bead + // whose metadata state it does not recognize. The reconciler skips such + // beads (forward-compatible rollback: an older reconciler ignores a newer + // writer's state rather than crashing), so this is the only durable signal + // that a bead is stuck outside the state machine. Emitted on first sight + // (and again with escalated=true once the bead has sat unrecognized past a + // threshold), never as a recovery action — pack-level subscribers or + // operators own recovery. See gastownhall/gascity#1497, #2085, #2389. + SessionUnknownState = "session.unknown_state" // SessionResetStalled fires when a session reset was committed but // the follow-up wake remains pending past the configured startup // timeout. Operators use the typed payload to correlate the stuck @@ -224,6 +233,7 @@ var KnownEventTypes = []string{ SessionIdleKilled, SessionMaxAgeKilled, SessionSuspended, SessionUpdated, SessionDrainAckedWithAssignedWork, SessionStranded, + SessionUnknownState, SessionResetStalled, SessionWorkQueryFailed, SessionColdStartTimeout, From 7c24516b585db2b396ed60b5272eabdc8cf440a9 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Thu, 9 Jul 2026 03:05:50 -0700 Subject: [PATCH 030/225] simplify(S11): collapse Manager Create API to CreateSpec + delete wrapper zoo (#4047) <9 Create* + 5 NewManager* deleted, all callers repointed to CreateSpec/Manager.CreateSession; behavior-preserving; worker-boundary + field-sync invariants hold; Fable red-team (transposition/worker-boundary/defaults/coverage) passed. #3789> --------- Co-authored-by: Claude Opus 4.8 (1M context) --- AGENTS.md | 19 +- cmd/gc/chat_autosuspend_test.go | 10 +- cmd/gc/cmd_nudge_test.go | 46 +- cmd/gc/cmd_restart_worker_boundary_test.go | 2 +- cmd/gc/nudge_dispatcher_test.go | 2 +- cmd/gc/session_lifecycle_chaos_test.go | 13 +- .../session_lifecycle_start_boundary_test.go | 2 +- .../session_lifecycle_worker_boundary_test.go | 4 +- cmd/gc/session_manager_test.go | 6 +- cmd/gc/session_model_phase0_spec_test.go | 4 +- cmd/gc/session_resolve_test.go | 23 +- cmd/gc/session_wake_test.go | 8 +- cmd/gc/worker_boundary_import_test.go | 4 +- cmd/gc/worker_handle_test.go | 36 +- internal/api/handler_agent_output_test.go | 40 +- internal/api/handler_session_agents_test.go | 15 +- internal/api/handler_session_chat_test.go | 6 +- internal/api/handler_session_create.go | 6 +- internal/api/handler_session_submit_test.go | 10 +- internal/api/handler_sessions_test.go | 211 +++-- internal/api/session_manager.go | 10 +- internal/api/session_resolution.go | 29 +- internal/api/session_response_wire_test.go | 2 +- internal/api/worker_factory_test.go | 73 +- internal/session/create_options.go | 40 + internal/session/create_options_test.go | 185 +++++ .../session/get_persisted_response_test.go | 4 +- internal/session/manager.go | 190 ++--- internal/session/manager_states_test.go | 14 +- internal/session/manager_test.go | 744 ++++++++---------- internal/session/submit_test.go | 110 +-- internal/worker/factory.go | 10 +- internal/worker/factory_test.go | 75 +- internal/worker/handle_lifecycle.go | 54 +- internal/worker/handle_test.go | 25 +- .../worker/invocation_telemetry_label_test.go | 2 +- internal/worker/invocation_telemetry_test.go | 4 +- .../invocation_telemetry_usagefact_test.go | 2 +- .../telemetry_handle_conformance_test.go | 2 +- .../worker_handle_live_helpers_test.go | 2 +- 40 files changed, 960 insertions(+), 1084 deletions(-) create mode 100644 internal/session/create_options.go create mode 100644 internal/session/create_options_test.go diff --git a/AGENTS.md b/AGENTS.md index 1be74c8319..378dca1182 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -251,18 +251,21 @@ the canonical route, not the legacy route. must route through `worker.Handle` — enforced by `TestGCNonTestFilesStayOnWorkerBoundary` in `cmd/gc/worker_boundary_import_test.go`, which forbids non-test - files from importing `session.NewManager(`, `worker.SessionHandle`, - `sessionlog`, and similar bypass paths in `cmd/gc`. The remaining - manager-construction/direct-create bypasses are split by category: - `internal/api/session_manager.go` constructs `session.Manager` values - for API handlers, and `internal/api/session_resolution.go` still calls - `mgr.CreateAliasedNamedWithTransportAndMetadata(...)` directly. This + files from importing `session.NewManagerWithOptions(`, + `worker.SessionHandle`, `sessionlog`, and similar bypass paths in + `cmd/gc`. The remaining manager-construction/direct-create bypasses + are split by category: `internal/api/session_manager.go` constructs + `session.Manager` values for API handlers, and + `internal/api/session_resolution.go` still calls + `mgr.CreateSession(...)` directly. Session creation goes through the + single `Manager.CreateSession(ctx, session.CreateOptions{...})` entry + point (`NewManagerWithOptions` is the sole Manager constructor). This list is not a sessionlog read-site inventory; stream and transcript readers in `internal/api/` and `internal/session/` still read session logs directly. Package-internal helpers in `internal/session/` may construct and use `session.Manager`; tests may construct it - directly. Do not add new non-test direct `session.Manager.Create*` call - sites outside the worker boundary. + directly. Do not add new non-test direct `session.Manager.CreateSession` + call sites outside the worker boundary. - **Session-first (completed `dd90ac0a` on Mar 8 2026).** The former Agent Protocol primitive was removed; responsibilities moved to `internal/session/` (lifecycle) and `internal/runtime/` (providers). diff --git a/cmd/gc/chat_autosuspend_test.go b/cmd/gc/chat_autosuspend_test.go index 464038ebab..49c30a9a4f 100644 --- a/cmd/gc/chat_autosuspend_test.go +++ b/cmd/gc/chat_autosuspend_test.go @@ -16,16 +16,16 @@ import ( func TestAutoSuspendChatSessions(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := session.NewManager(store, sp) + mgr := session.NewManagerWithOptions(store, sp) now := time.Date(2026, 3, 11, 12, 0, 0, 0, time.UTC) clk := &clock.Fake{Time: now} // Create two sessions. - s1, err := mgr.Create(context.Background(), "default", "S1", "echo s1", "/tmp", "test", nil, session.ProviderResume{}, runtime.Config{}) + s1, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "default", Title: "S1", Command: "echo s1", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } - s2, err := mgr.Create(context.Background(), "default", "S2", "echo s2", "/tmp", "test", nil, session.ProviderResume{}, runtime.Config{}) + s2, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "default", Title: "S2", Command: "echo s2", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } @@ -71,11 +71,11 @@ func TestAutoSuspendChatSessions(t *testing.T) { func TestAutoSuspendSkipsAttachedSessions(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := session.NewManager(store, sp) + mgr := session.NewManagerWithOptions(store, sp) now := time.Date(2026, 3, 11, 12, 0, 0, 0, time.UTC) clk := &clock.Fake{Time: now} - s1, err := mgr.Create(context.Background(), "default", "Attached", "echo a", "/tmp", "test", nil, session.ProviderResume{}, runtime.Config{}) + s1, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "default", Title: "Attached", Command: "echo a", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } diff --git a/cmd/gc/cmd_nudge_test.go b/cmd/gc/cmd_nudge_test.go index 670ebf216e..7331bbcf24 100644 --- a/cmd/gc/cmd_nudge_test.go +++ b/cmd/gc/cmd_nudge_test.go @@ -514,7 +514,7 @@ func TestDeliverSessionNudgeWithWorkerImmediateResumesSuspendedSession(t *testin fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "claude", dir, "claude", nil, session.ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Worker", Command: "claude", WorkDir: dir, Provider: "claude", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -566,7 +566,7 @@ func TestDeliverSessionNudgeWithWorkerWaitIdleResumesClaudeSession(t *testing.T) fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "claude", dir, "claude", nil, session.ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Worker", Command: "claude", WorkDir: dir, Provider: "claude", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -623,7 +623,7 @@ func TestDeliverSessionNudgeWithWorkerManagedNonRunningQueuesWakeForController(t fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "claude", dir, "claude", nil, session.ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Worker", Command: "claude", WorkDir: dir, Provider: "claude", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -706,7 +706,7 @@ func TestDeliverSessionNudgeWithWorkerManagedQueueFailureDoesNotWake(t *testing. fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "claude", dir, "claude", nil, session.ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Worker", Command: "claude", WorkDir: dir, Provider: "claude", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -782,7 +782,7 @@ func TestDeliverSessionNudgeWithWorkerManagedWakeFailureRollsBackQueuedNudge(t * fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "claude", dir, "claude", nil, session.ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Worker", Command: "claude", WorkDir: dir, Provider: "claude", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -864,7 +864,7 @@ func TestDeliverSessionNudgeWithWorkerManagedWaitNudgeWithdrawFailureKeepsQueued fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "claude", dir, "claude", nil, session.ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Worker", Command: "claude", WorkDir: dir, Provider: "claude", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -976,7 +976,7 @@ func TestDeliverSessionNudgeWithWorkerManagedObserveErrorDoesNotResumeFromCaller fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "claude", dir, "claude", nil, session.ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Worker", Command: "claude", WorkDir: dir, Provider: "claude", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1050,7 +1050,7 @@ func TestDeliverSessionNudgeWithWorkerWaitIdleQueuesUnsupportedProviderAfterResu fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "codex", dir, "codex", nil, session.ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Worker", Command: "codex", WorkDir: dir, Provider: "codex", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1532,7 +1532,7 @@ func TestSendMailNotifyWithWorkerManagedNonRunningQueuesWakeForController(t *tes fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "claude", dir, "claude", nil, session.ProviderResume{}, runtime.Config{WorkDir: dir}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Worker", Command: "claude", WorkDir: dir, Provider: "claude", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{WorkDir: dir}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1613,7 +1613,7 @@ func TestSendMailNotifyWithWorkerManagedQueueFailureDoesNotWake(t *testing.T) { fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "claude", dir, "claude", nil, session.ProviderResume{}, runtime.Config{WorkDir: dir}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Worker", Command: "claude", WorkDir: dir, Provider: "claude", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{WorkDir: dir}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1692,7 +1692,7 @@ func TestSendMailNotifyQueuesIndependentRemindersForEachMail(t *testing.T) { fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "mayor", "Mayor", "claude", dir, "claude", nil, session.ProviderResume{}, runtime.Config{WorkDir: dir}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "mayor", Title: "Mayor", Command: "claude", WorkDir: dir, Provider: "claude", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{WorkDir: dir}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1740,7 +1740,7 @@ func TestSendMailNotifyWithWorkerManagedWakeFailureRollsBackQueuedNudge(t *testi fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "claude", dir, "claude", nil, session.ProviderResume{}, runtime.Config{WorkDir: dir}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Worker", Command: "claude", WorkDir: dir, Provider: "claude", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{WorkDir: dir}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1818,7 +1818,7 @@ func TestSendMailNotifyWithWorkerManagedWaitNudgeWithdrawFailureKeepsQueuedNudge fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "claude", dir, "claude", nil, session.ProviderResume{}, runtime.Config{WorkDir: dir}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Worker", Command: "claude", WorkDir: dir, Provider: "claude", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{WorkDir: dir}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1925,7 +1925,7 @@ func TestSendMailNotifyWithWorkerManagedWakePokeFailureIsNonFatal(t *testing.T) fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "claude", dir, "claude", nil, session.ProviderResume{}, runtime.Config{WorkDir: dir}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Worker", Command: "claude", WorkDir: dir, Provider: "claude", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{WorkDir: dir}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2080,7 +2080,7 @@ func TestSendMailNotifyWithWorkerStartsPollerBySessionIDForAliasedTarget(t *test store := openNudgeBeadStore(dir) fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "mayor", "Mayor", "codex", dir, "codex", nil, session.ProviderResume{}, runtime.Config{WorkDir: dir}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "mayor", Title: "Mayor", Command: "codex", WorkDir: dir, Provider: "codex", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{WorkDir: dir}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2192,7 +2192,7 @@ func TestSendMailNotifyWithWorkerWaitIdlePreservesMailSource(t *testing.T) { fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "mayor", "Mayor", "claude", dir, "claude", nil, session.ProviderResume{}, runtime.Config{WorkDir: dir}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "mayor", Title: "Mayor", Command: "claude", WorkDir: dir, Provider: "claude", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{WorkDir: dir}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2235,7 +2235,7 @@ func TestSendMailNotifyWithWorkerQueuesWhenRuntimeIsGone(t *testing.T) { fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "mayor", "Mayor", "claude", dir, "claude", nil, session.ProviderResume{}, runtime.Config{WorkDir: dir}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "mayor", Title: "Mayor", Command: "claude", WorkDir: dir, Provider: "claude", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{WorkDir: dir}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2284,7 +2284,7 @@ func TestSendMailNotifyWithWorkerQueuesWhenDirectProviderMisses(t *testing.T) { fake := &providerMissNudgeProvider{Fake: runtime.NewFake()} mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "codex", dir, "codex", nil, session.ProviderResume{}, runtime.Config{WorkDir: dir}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Worker", Command: "codex", WorkDir: dir, Provider: "codex", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{WorkDir: dir}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2434,7 +2434,7 @@ func TestTryDeliverQueuedNudgesByPollerDeliversAndAcks(t *testing.T) { store := openNudgeBeadStore(dir) fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "codex", dir, "codex", nil, session.ProviderResume{}, runtime.Config{WorkDir: dir}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Worker", Command: "codex", WorkDir: dir, Provider: "codex", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{WorkDir: dir}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2503,7 +2503,7 @@ func TestTryDeliverQueuedNudgesByPollerDeliversActivitylessTimedOnlySession(t *t store := openNudgeBeadStore(dir) fake := &activitylessTimedOnlyNudgeProvider{Fake: runtime.NewFake()} mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "codex", dir, "codex", nil, session.ProviderResume{}, runtime.Config{WorkDir: dir}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Worker", Command: "codex", WorkDir: dir, Provider: "codex", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{WorkDir: dir}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2776,7 +2776,7 @@ func TestTryDeliverQueuedNudgesByPollerReleasesClaimsWhenDeliveryDeclined(t *tes store := openNudgeBeadStore(dir) fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "codex", dir, "codex", nil, session.ProviderResume{}, runtime.Config{WorkDir: dir}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Worker", Command: "codex", WorkDir: dir, Provider: "codex", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{WorkDir: dir}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2830,7 +2830,7 @@ func TestTryDeliverQueuedNudgesByPollerDeliversDespiteStaleFenceBeadMarkFailure( store := &failingTerminalNudgeStore{MemStore: beads.NewMemStore()} fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "codex", dir, "codex", nil, session.ProviderResume{}, runtime.Config{WorkDir: dir}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Worker", Command: "codex", WorkDir: dir, Provider: "codex", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{WorkDir: dir}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -3117,7 +3117,7 @@ func TestDeliverSlingNudgeWaitIdleWrapsInSystemReminder(t *testing.T) { fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store, fake, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "claude", dir, "claude", nil, session.ProviderResume{}, runtime.Config{WorkDir: dir}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Worker", Command: "claude", WorkDir: dir, Provider: "claude", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{WorkDir: dir}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } diff --git a/cmd/gc/cmd_restart_worker_boundary_test.go b/cmd/gc/cmd_restart_worker_boundary_test.go index 828b7f3909..6d26b2adaa 100644 --- a/cmd/gc/cmd_restart_worker_boundary_test.go +++ b/cmd/gc/cmd_restart_worker_boundary_test.go @@ -18,7 +18,7 @@ func TestDoRigRestartUsesWorkerBoundaryForKnownSession(t *testing.T) { sp := runtime.NewFake() store := beads.NewMemStore() mgr := newSessionManagerWithConfig("", store, sp, nil) - info, err := mgr.Create(context.Background(), "frontend/worker", "Worker", "claude", t.TempDir(), "claude", nil, sessionpkg.ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), sessionpkg.CreateOptions{Template: "frontend/worker", Title: "Worker", Command: "claude", WorkDir: t.TempDir(), Provider: "claude", Env: nil, Resume: sessionpkg.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } diff --git a/cmd/gc/nudge_dispatcher_test.go b/cmd/gc/nudge_dispatcher_test.go index 144e6af581..f88f063d86 100644 --- a/cmd/gc/nudge_dispatcher_test.go +++ b/cmd/gc/nudge_dispatcher_test.go @@ -183,7 +183,7 @@ func TestDispatchAllQueuedNudgesDeliversAndAcks(t *testing.T) { store := openNudgeBeadStore(dir) fake := runtime.NewFake() mgr := newSessionManagerWithConfig(dir, store.Store, fake, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "codex", dir, "codex", nil, session.ProviderResume{}, runtime.Config{WorkDir: dir}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Worker", Command: "codex", WorkDir: dir, Provider: "codex", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{WorkDir: dir}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } diff --git a/cmd/gc/session_lifecycle_chaos_test.go b/cmd/gc/session_lifecycle_chaos_test.go index 4a2d24a090..0dfbbf07c8 100644 --- a/cmd/gc/session_lifecycle_chaos_test.go +++ b/cmd/gc/session_lifecycle_chaos_test.go @@ -1006,7 +1006,7 @@ func newSessionChaosHarness(t *testing.T, seed int64) *sessionChaosHarness { return &sessionChaosHarness{ t: t, env: env, - manager: sessionpkg.NewManager(env.store, env.sp), + manager: sessionpkg.NewManagerWithOptions(env.store, env.sp), rng: rand.New(rand.NewSource(seed)), //nolint:gosec // deterministic test chaos, not security-sensitive. seed: seed, template: template, @@ -1018,16 +1018,7 @@ func (h *sessionChaosHarness) createSessionIntent() { if h.sessionID != "" { return } - info, err := h.manager.CreateBeadOnly( - h.template, - "Chaos worker", - h.command, - "", - "fake", - "", - nil, - sessionpkg.ProviderResume{}, - ) + info, err := h.manager.CreateSession(context.Background(), sessionpkg.CreateOptions{BeadOnly: true, Template: h.template, Title: "Chaos worker", Command: h.command, WorkDir: "", Provider: "fake", Transport: "", Resume: sessionpkg.ProviderResume{}}) if err != nil { h.failf("CreateBeadOnly: %v", err) } diff --git a/cmd/gc/session_lifecycle_start_boundary_test.go b/cmd/gc/session_lifecycle_start_boundary_test.go index 174261e535..97020709c2 100644 --- a/cmd/gc/session_lifecycle_start_boundary_test.go +++ b/cmd/gc/session_lifecycle_start_boundary_test.go @@ -14,7 +14,7 @@ func TestExecutePreparedStartWaveUsesWorkerBoundaryForKnownSession(t *testing.T) store := beads.NewMemStore() sp := runtime.NewFake() mgr := newSessionManagerWithConfig("", store, sp, nil) - info, err := mgr.CreateBeadOnly("worker", "Worker", "claude", t.TempDir(), "claude", "", nil, sessionpkg.ProviderResume{}) + info, err := mgr.CreateSession(context.Background(), sessionpkg.CreateOptions{BeadOnly: true, Template: "worker", Title: "Worker", Command: "claude", WorkDir: t.TempDir(), Provider: "claude", Transport: "", Resume: sessionpkg.ProviderResume{}}) if err != nil { t.Fatalf("CreateBeadOnly: %v", err) } diff --git a/cmd/gc/session_lifecycle_worker_boundary_test.go b/cmd/gc/session_lifecycle_worker_boundary_test.go index 94cff48842..c599e4d939 100644 --- a/cmd/gc/session_lifecycle_worker_boundary_test.go +++ b/cmd/gc/session_lifecycle_worker_boundary_test.go @@ -16,7 +16,7 @@ func TestStopTargetsBoundedUsesWorkerBoundaryForKnownSession(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() mgr := newSessionManagerWithConfig("", store, sp, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "claude", t.TempDir(), "claude", nil, sessionpkg.ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), sessionpkg.CreateOptions{Template: "worker", Title: "Worker", Command: "claude", WorkDir: t.TempDir(), Provider: "claude", Env: nil, Resume: sessionpkg.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -53,7 +53,7 @@ func TestInterruptTargetsBoundedStopsPoolManagedSessionsThroughWorkerBoundary(t if err := sp.Start(context.Background(), "human-worker", runtime.Config{}); err != nil { t.Fatal(err) } - poolInfo, err := mgr.Create(context.Background(), "pool", "Pool", "claude", t.TempDir(), "claude", nil, sessionpkg.ProviderResume{}, runtime.Config{}) + poolInfo, err := mgr.CreateSession(context.Background(), sessionpkg.CreateOptions{Template: "pool", Title: "Pool", Command: "claude", WorkDir: t.TempDir(), Provider: "claude", Env: nil, Resume: sessionpkg.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } diff --git a/cmd/gc/session_manager_test.go b/cmd/gc/session_manager_test.go index d4f86a2819..c54e3ebea1 100644 --- a/cmd/gc/session_manager_test.go +++ b/cmd/gc/session_manager_test.go @@ -11,10 +11,10 @@ import ( func newSessionManagerWithConfig(cityPath string, store beads.Store, sp runtime.Provider, cfg *config.City) *session.Manager { if cfg == nil { - return session.NewManagerWithCityPath(store, sp, cityPath) + return session.NewManagerWithOptions(store, sp, session.WithCityPath(cityPath)) } rigContext := currentRigContext(cfg) - return session.NewManagerWithTransportPolicyResolverAndCityPath(store, sp, cityPath, func(template, provider string) (string, bool) { + return session.NewManagerWithOptions(store, sp, session.WithCityPath(cityPath), session.WithTransportPolicyResolver(func(template, provider string) (string, bool) { agentCfg, ok := resolveAgentIdentity(cfg, template, rigContext) if ok { resolved, err := config.ResolveProvider( @@ -45,5 +45,5 @@ func newSessionManagerWithConfig(cityPath string, store beads.Store, sp runtime. return "", false } return strings.TrimSpace(resolved.ProviderSessionCreateTransport()), false - }) + })) } diff --git a/cmd/gc/session_model_phase0_spec_test.go b/cmd/gc/session_model_phase0_spec_test.go index bbe8037a70..3b5e98454d 100644 --- a/cmd/gc/session_model_phase0_spec_test.go +++ b/cmd/gc/session_model_phase0_spec_test.go @@ -150,9 +150,9 @@ func TestPhase0SessionResolution_RigScopedBareNamedIdentityRequiresAmbientRig(t func TestPhase0CanonicalMetadata_ManualCreateWritesSessionOrigin(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := session.NewManager(store, sp) + mgr := session.NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "worker", "Worker", "echo test", t.TempDir(), "test-provider", nil, session.ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Worker", Command: "echo test", WorkDir: t.TempDir(), Provider: "test-provider", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } diff --git a/cmd/gc/session_resolve_test.go b/cmd/gc/session_resolve_test.go index bbf8f119b6..143bb59d6f 100644 --- a/cmd/gc/session_resolve_test.go +++ b/cmd/gc/session_resolve_test.go @@ -972,28 +972,15 @@ func TestResolveSessionIDMaterializingNamed_RecreatesClosedConfiguredNamedSessio Template: "mayor", }}, } - mgr := session.NewManager(store, runtime.NewFake()) - info, err := mgr.CreateAliasedNamedWithTransportAndMetadata( - context.Background(), - "mayor", - config.NamedSessionRuntimeName(cfg.EffectiveCityName(), cfg.Workspace, "mayor"), - "mayor", - "Mayor", - "true", - t.TempDir(), - "shell", - "", - nil, - session.ProviderResume{}, - runtime.Config{}, - map[string]string{ + mgr := session.NewManagerWithOptions(store, runtime.NewFake()) + info, err := mgr.CreateSession( + context.Background(), session.CreateOptions{Alias: "mayor", ExplicitName: config.NamedSessionRuntimeName(cfg.EffectiveCityName(), cfg.Workspace, "mayor"), Template: "mayor", Title: "Mayor", Command: "true", WorkDir: t.TempDir(), Provider: "shell", Transport: "", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{ namedSessionMetadataKey: "true", namedSessionIdentityMetadata: "mayor", namedSessionModeMetadata: "on_demand", - }, - ) + }}) if err != nil { - t.Fatalf("CreateAliasedNamedWithTransportAndMetadata: %v", err) + t.Fatalf("CreateSessionAliasedNamedWithTransportAndMetadata: %v", err) } if err := mgr.Close(info.ID); err != nil { t.Fatalf("Close: %v", err) diff --git a/cmd/gc/session_wake_test.go b/cmd/gc/session_wake_test.go index 0b41b09cba..42a5a71d89 100644 --- a/cmd/gc/session_wake_test.go +++ b/cmd/gc/session_wake_test.go @@ -573,7 +573,7 @@ func TestVerifiedStop_MatchingToken(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() mgr := newSessionManagerWithConfig("", store, sp, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "claude", t.TempDir(), "claude", nil, sessionpkg.ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), sessionpkg.CreateOptions{Template: "worker", Title: "Worker", Command: "claude", WorkDir: t.TempDir(), Provider: "claude", Env: nil, Resume: sessionpkg.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -595,7 +595,7 @@ func TestVerifiedStop_MismatchedToken(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() mgr := newSessionManagerWithConfig("", store, sp, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "claude", t.TempDir(), "claude", nil, sessionpkg.ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), sessionpkg.CreateOptions{Template: "worker", Title: "Worker", Command: "claude", WorkDir: t.TempDir(), Provider: "claude", Env: nil, Resume: sessionpkg.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -623,7 +623,7 @@ func TestVerifiedStop_NoToken(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() mgr := newSessionManagerWithConfig("", store, sp, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "claude", t.TempDir(), "claude", nil, sessionpkg.ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), sessionpkg.CreateOptions{Template: "worker", Title: "Worker", Command: "claude", WorkDir: t.TempDir(), Provider: "claude", Env: nil, Resume: sessionpkg.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -645,7 +645,7 @@ func TestVerifiedInterrupt_MismatchedToken(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() mgr := newSessionManagerWithConfig("", store, sp, nil) - info, err := mgr.Create(context.Background(), "worker", "Worker", "claude", t.TempDir(), "claude", nil, sessionpkg.ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), sessionpkg.CreateOptions{Template: "worker", Title: "Worker", Command: "claude", WorkDir: t.TempDir(), Provider: "claude", Env: nil, Resume: sessionpkg.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } diff --git a/cmd/gc/worker_boundary_import_test.go b/cmd/gc/worker_boundary_import_test.go index 2ca21c3820..5d6f213976 100644 --- a/cmd/gc/worker_boundary_import_test.go +++ b/cmd/gc/worker_boundary_import_test.go @@ -36,9 +36,7 @@ func TestGCNonTestFilesStayOnWorkerBoundary(t *testing.T) { "worker.SessionHandle", "worker.SessionSpec", "worker.SessionLogAdapter{", - "session.NewManager(", - "session.NewManagerWithCityPath(", - "session.NewManagerWithTransportResolverAndCityPath(", + "session.NewManagerWithOptions(", "sp.Start(ctx,", "setBeadRestartRequested(", } { diff --git a/cmd/gc/worker_handle_test.go b/cmd/gc/worker_handle_test.go index 8c8a7af86d..7cee1e79b2 100644 --- a/cmd/gc/worker_handle_test.go +++ b/cmd/gc/worker_handle_test.go @@ -64,9 +64,9 @@ STUB_ENV = "present" sp := runtime.NewFake() mgr := newSessionManagerWithConfig(cityDir, store, sp, cfg) - info, err := mgr.CreateBeadOnly("worker", "Probe", "", t.TempDir(), "stub", "", nil, session.ProviderResume{ + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{BeadOnly: true, Template: "worker", Title: "Probe", Command: "", WorkDir: t.TempDir(), Provider: "stub", Transport: "", Resume: session.ProviderResume{ SessionIDFlag: "--old-session-id", - }) + }}) if err != nil { t.Fatalf("CreateBeadOnly: %v", err) } @@ -1244,21 +1244,12 @@ session_id_flag = "--session-id" sp := runtime.NewFake() mgr := newSessionManagerWithConfig(cityDir, store, sp, cfg) - info, err := mgr.Create( - context.Background(), - "worker", - "Probe", - "legacy-agent", - t.TempDir(), - "stub", - nil, - session.ProviderResume{ + info, err := mgr.CreateSession( + context.Background(), session.CreateOptions{Template: "worker", Title: "Probe", Command: "legacy-agent", WorkDir: t.TempDir(), Provider: "stub", Env: nil, Resume: session.ProviderResume{ ResumeFlag: "--old-resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", - }, - runtime.Config{}, - ) + }, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1319,17 +1310,8 @@ session_id_flag = "--session-id" sp := runtime.NewFake() mgr := newSessionManagerWithConfig(cityDir, store, sp, cfg) - info, err := mgr.Create( - context.Background(), - "worker", - "Probe", - "", - t.TempDir(), - "stub", - nil, - session.ProviderResume{ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id"}, - runtime.Config{}, - ) + info, err := mgr.CreateSession( + context.Background(), session.CreateOptions{Template: "worker", Title: "Probe", Command: "", WorkDir: t.TempDir(), Provider: "stub", Env: nil, Resume: session.ProviderResume{ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id"}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1380,7 +1362,7 @@ command = "/bin/echo" } sp := runtime.NewFake() mgr := newSessionManagerWithConfig(cityDir, backing, sp, cfg) - info, err := mgr.Create(context.Background(), "worker", "Probe", "/bin/echo", t.TempDir(), "stub", nil, session.ProviderResume{}, runtime.Config{Command: "/bin/echo"}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Probe", Command: "/bin/echo", WorkDir: t.TempDir(), Provider: "stub", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{Command: "/bin/echo"}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1478,7 +1460,7 @@ command = "/bin/echo" } sp := runtime.NewFake() mgr := newSessionManagerWithConfig(cityDir, store, sp, cfg) - info, err := mgr.Create(context.Background(), "worker", "Probe", "stub", t.TempDir(), "stub", nil, session.ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "worker", Title: "Probe", Command: "stub", WorkDir: t.TempDir(), Provider: "stub", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } diff --git a/internal/api/handler_agent_output_test.go b/internal/api/handler_agent_output_test.go index b4c34b157d..1b12c921f4 100644 --- a/internal/api/handler_agent_output_test.go +++ b/internal/api/handler_agent_output_test.go @@ -85,8 +85,8 @@ func newGeminiAgentOutputStreamFixture(t *testing.T) *geminiAgentOutputStreamFix t.Fatalf("chtimes(first transcript): %v", err) } - mgr := session.NewManager(fs.cityBeadStore, fs.sp) - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "gemini", workDir, "gemini", nil, session.ProviderResume{}, runtime.Config{}) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "gemini", WorkDir: workDir, Provider: "gemini", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -336,22 +336,10 @@ func TestResolveAgentTranscriptUsesBeadSessionIDWhenRuntimeMetaMissing(t *testin } srv := newServerWithSearchPaths(state, searchBase) - mgr := session.NewManager(state.cityBeadStore, state.sp) + mgr := session.NewManagerWithOptions(state.cityBeadStore, state.sp) sessionName := agentSessionName(state.CityName(), "myrig/worker", state.cfg.Workspace.SessionTemplate) - info, err := mgr.CreateAliasedNamedWithTransport( - context.Background(), - "", - sessionName, - "myrig/worker", - "Chat", - "claude", - workDir, - "claude/tmux-cli", - "", - nil, - session.ProviderResume{}, - runtime.Config{}, - ) + info, err := mgr.CreateSession( + context.Background(), session.CreateOptions{Alias: "", ExplicitName: sessionName, Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude/tmux-cli", Transport: "", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -736,22 +724,10 @@ func TestAgentOutputStreamWorkerOperationEventWakesPeekFallback(t *testing.T) { func TestAgentOutputStreamWorkerOperationSessionIDWakesPeekFallback(t *testing.T) { state := newSessionFakeState(t) - mgr := session.NewManager(state.cityBeadStore, state.sp) + mgr := session.NewManagerWithOptions(state.cityBeadStore, state.sp) sessionName := agentSessionName(state.CityName(), "myrig/worker", state.cfg.Workspace.SessionTemplate) - info, err := mgr.CreateAliasedNamedWithTransport( - context.Background(), - "", - sessionName, - "myrig/worker", - "Chat", - "claude", - t.TempDir(), - "claude", - "", - nil, - session.ProviderResume{}, - runtime.Config{}, - ) + info, err := mgr.CreateSession( + context.Background(), session.CreateOptions{Alias: "", ExplicitName: sessionName, Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: t.TempDir(), Provider: "claude", Transport: "", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } diff --git a/internal/api/handler_session_agents_test.go b/internal/api/handler_session_agents_test.go index b8d5b58cfa..f87c210373 100644 --- a/internal/api/handler_session_agents_test.go +++ b/internal/api/handler_session_agents_test.go @@ -18,18 +18,9 @@ import ( func createTranscriptBackedSession(t *testing.T, store beads.Store, sp *runtime.Fake, workDir string) session.Info { t.Helper() - mgr := session.NewManager(store, sp) - info, err := mgr.Create( - context.Background(), - "default", - "Transcript Backed", - "echo test", - workDir, - "test", - nil, - session.ProviderResume{}, - runtime.Config{}, - ) + mgr := session.NewManagerWithOptions(store, sp) + info, err := mgr.CreateSession( + context.Background(), session.CreateOptions{Template: "default", Title: "Transcript Backed", Command: "echo test", WorkDir: workDir, Provider: "test", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("create session: %v", err) } diff --git a/internal/api/handler_session_chat_test.go b/internal/api/handler_session_chat_test.go index 0c3eb5471e..aad847362b 100644 --- a/internal/api/handler_session_chat_test.go +++ b/internal/api/handler_session_chat_test.go @@ -113,13 +113,13 @@ func TestBuildSessionResumeAppliesTemplateOverridesToExplicitResumeCommand(t *te }, }, } - mgr := session.NewManager(fs.cityBeadStore, fs.sp) - info, err := mgr.Create(context.Background(), "codex-provider", "chat", "codex --ask-for-approval on-request", "/tmp/workdir", "codex-provider", nil, session.ProviderResume{ + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "codex-provider", Title: "chat", Command: "codex --ask-for-approval on-request", WorkDir: "/tmp/workdir", Provider: "codex-provider", Env: nil, Resume: session.ProviderResume{ ResumeFlag: "resume", ResumeStyle: "subcommand", ResumeCommand: "codex resume {{.SessionKey}} --ask-for-approval on-request", SessionIDFlag: "--session-id", - }, runtime.Config{}) + }, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } diff --git a/internal/api/handler_session_create.go b/internal/api/handler_session_create.go index 143f399ca6..839598b9d9 100644 --- a/internal/api/handler_session_create.go +++ b/internal/api/handler_session_create.go @@ -221,9 +221,9 @@ func (s *Server) handleSessionCreate(w http.ResponseWriter, r *http.Request) { // Persist kind, option metadata, and project_id on the bead. // NOTE: template_overrides (options + initial_message) is already set via - // extraMeta in CreateAliasedBeadOnlyNamedWithMetadata above. Do NOT - // overwrite it here — the old code clobbered initial_message by writing - // only the options portion. + // extraMeta on the deferred handle.Create (Manager.CreateSession) above. + // Do NOT overwrite it here — the old code clobbered initial_message by + // writing only the options portion. s.persistSessionMeta(store, info.ID, body.ProjectID, optMeta) s.state.Poke() // wake reconciler to start the agent diff --git a/internal/api/handler_session_submit_test.go b/internal/api/handler_session_submit_test.go index 6eb0d755db..0704b81f7d 100644 --- a/internal/api/handler_session_submit_test.go +++ b/internal/api/handler_session_submit_test.go @@ -19,7 +19,7 @@ func TestHandleSessionSubmitDefaultsToProviderDefaultBehavior(t *testing.T) { h := newTestCityHandler(t, fs) info := createTestSession(t, fs.cityBeadStore, fs.sp, "Submit Me") - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) if err := mgr.Suspend(info.ID); err != nil { t.Fatalf("Suspend: %v", err) } @@ -56,8 +56,8 @@ func TestHandleSessionSubmitUsesImmediateDefaultForCodex(t *testing.T) { fs := newSessionFakeState(t) h := newTestCityHandler(t, fs) - mgr := session.NewManager(fs.cityBeadStore, fs.sp) - info, err := mgr.Create(context.Background(), "helper", "Codex Submit", "codex", t.TempDir(), "codex", nil, session.ProviderResume{}, runtime.Config{}) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "helper", Title: "Codex Submit", Command: "codex", WorkDir: t.TempDir(), Provider: "codex", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -165,8 +165,8 @@ func TestHandleSessionStopUsesSoftEscapeForCodex(t *testing.T) { fs := newSessionFakeState(t) h := newTestCityHandler(t, fs) - mgr := session.NewManager(fs.cityBeadStore, fs.sp) - info, err := mgr.Create(context.Background(), "helper", "Codex", "codex", t.TempDir(), "codex", nil, session.ProviderResume{}, runtime.Config{}) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "helper", Title: "Codex", Command: "codex", WorkDir: t.TempDir(), Provider: "codex", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } diff --git a/internal/api/handler_sessions_test.go b/internal/api/handler_sessions_test.go index 8f3c39954e..46d2387ac0 100644 --- a/internal/api/handler_sessions_test.go +++ b/internal/api/handler_sessions_test.go @@ -187,8 +187,8 @@ func waitForNSessionCreateEvents(t *testing.T, prov events.Provider, n int, time func createTestSession(t *testing.T, store beads.Store, sp *runtime.Fake, title string) session.Info { t.Helper() - mgr := session.NewManager(store, sp) - info, err := mgr.Create(context.Background(), "default", title, "echo test", "/tmp", "test", nil, session.ProviderResume{}, runtime.Config{}) + mgr := session.NewManagerWithOptions(store, sp) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "default", Title: title, Command: "echo test", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("create session: %v", err) } @@ -197,7 +197,7 @@ func createTestSession(t *testing.T, store beads.Store, sp *runtime.Fake, title func suspendSessionForPermissionModeTest(t *testing.T, fs *fakeState, id string) { t.Helper() - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) if err := mgr.Suspend(id); err != nil { t.Fatalf("suspend session: %v", err) } @@ -716,7 +716,7 @@ func TestHandleSessionListFilterByState(t *testing.T) { createTestSession(t, fs.cityBeadStore, fs.sp, "Stay Active") // Suspend one. - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) if err := mgr.Suspend(info.ID); err != nil { t.Fatalf("suspend: %v", err) } @@ -925,8 +925,8 @@ func TestHandleSessionListSkipsWorkdirOnlyCodexTranscriptDiscovery(t *testing.T) h := newTestCityHandlerWith(t, fs, srv) workDir := t.TempDir() - mgr := session.NewManager(fs.cityBeadStore, fs.sp) - info, err := mgr.Create(context.Background(), "myrig/worker", "Codex Chat", "codex", workDir, "codex-max", nil, session.ProviderResume{}, runtime.Config{}) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Codex Chat", Command: "codex", WorkDir: workDir, Provider: "codex-max", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -981,8 +981,8 @@ func TestHandleSessionGetAllowsWorkdirOnlyCodexTranscriptDiscovery(t *testing.T) h := newTestCityHandlerWith(t, fs, srv) workDir := t.TempDir() - mgr := session.NewManager(fs.cityBeadStore, fs.sp) - info, err := mgr.Create(context.Background(), "myrig/worker", "Codex Chat", "codex", workDir, "codex-max", nil, session.ProviderResume{}, runtime.Config{}) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Codex Chat", Command: "codex", WorkDir: workDir, Provider: "codex-max", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1118,7 +1118,7 @@ func TestHandleSessionSuspend(t *testing.T) { } // Verify the session is now suspended. - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) got, err := mgr.Get(info.ID) if err != nil { t.Fatalf("get: %v", err) @@ -1142,7 +1142,7 @@ func TestHandleSessionSuspend_IllegalTransition(t *testing.T) { // Drain the session directly via the manager (the API surface for drain // lives elsewhere; this test isolates the transition check). - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) if err := mgr.BeginDrain(info.ID, "shutdown"); err != nil { t.Fatalf("BeginDrain: %v", err) } @@ -1202,7 +1202,7 @@ func TestHandleSessionClose(t *testing.T) { } // Session should no longer appear in default listing (excludes closed). - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) sessions, err := mgr.List("", "") if err != nil { t.Fatalf("list: %v", err) @@ -1648,7 +1648,7 @@ func TestHandleSessionWakeStartsSuspendedRuntime(t *testing.T) { h := newTestCityHandlerWith(t, fs, srv) info := createTestSession(t, fs.cityBeadStore, fs.sp, "Suspended Session") - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) if err := mgr.Suspend(info.ID); err != nil { t.Fatalf("Suspend: %v", err) } @@ -1679,7 +1679,7 @@ func TestHandleSessionWakeClosed(t *testing.T) { _ = h info := createTestSession(t, fs.cityBeadStore, fs.sp, "Closed Session") - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) _ = mgr.Close(info.ID) w := httptest.NewRecorder() @@ -1805,18 +1805,9 @@ func TestHandleSessionPatchRejectsReservedQualifiedAliasOnFork(t *testing.T) { h := newTestCityHandlerWith(t, fs, srv) _ = h - mgr := session.NewManager(fs.cityBeadStore, fs.sp) - info, err := mgr.Create( - context.Background(), - "myrig/worker", - "Fork", - "claude", - t.TempDir(), - "claude", - nil, - session.ProviderResume{}, - runtime.Config{}, - ) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) + info, err := mgr.CreateSession( + context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Fork", Command: "claude", WorkDir: t.TempDir(), Provider: "claude", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -3379,7 +3370,7 @@ func TestHandleProviderSessionCreateWithMessageRollsBackOnDeliveryFailure(t *tes if failure.ErrorCode != "message_delivery_failed" { t.Fatalf("failure error_code = %q, want message_delivery_failed; message=%s", failure.ErrorCode, failure.ErrorMessage) } - mgr := session.NewManager(fs.cityBeadStore, provider) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, provider) sessions, err := mgr.List("", "") if err != nil { t.Fatalf("list sessions after rollback: %v", err) @@ -4077,7 +4068,7 @@ func TestHandleSessionPermissionModePreservesProviderCreateOptions(t *testing.T) t.Fatalf("response options.effort = %q, want high from create-time provider option", got) } - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) info, err := mgr.Get(success.Session.ID) if err != nil { t.Fatalf("Get session: %v", err) @@ -4238,8 +4229,8 @@ func TestHandleSessionGetUsesAgentDefaultsForConfiguredNamedSession(t *testing.T srv := New(fs) h := newTestCityHandlerWith(t, fs, srv) - mgr := session.NewManager(fs.cityBeadStore, fs.sp) - info, err := mgr.Create(context.Background(), "myrig/worker", "worker", "echo test", "/tmp", "test-agent", nil, session.ProviderResume{}, runtime.Config{}) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "worker", Command: "echo test", WorkDir: "/tmp", Provider: "test-agent", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("create session: %v", err) } @@ -4288,8 +4279,8 @@ func TestHandleSessionGetUsesLegacyProviderKindForNameCollision(t *testing.T) { srv := New(fs) h := newTestCityHandlerWith(t, fs, srv) - mgr := session.NewManager(fs.cityBeadStore, fs.sp) - info, err := mgr.Create(context.Background(), "codex", "codex", "echo", "/tmp/provider", "codex", nil, session.ProviderResume{}, runtime.Config{}) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "codex", Title: "codex", Command: "echo", WorkDir: "/tmp/provider", Provider: "codex", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("create session: %v", err) } @@ -4560,7 +4551,7 @@ func TestHandleSessionMessageQueuesSuspendedSessionMessage(t *testing.T) { fs := newSessionFakeState(t) info := createTestSession(t, fs.cityBeadStore, fs.sp, "Resume Me") - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) if err := mgr.Suspend(info.ID); err != nil { t.Fatalf("Suspend: %v", err) } @@ -4889,23 +4880,11 @@ func TestHandleSessionGetReservedNamedTargetIgnoresClosedHistoricalBead(t *testi h := newTestCityHandlerWith(t, fs, srv) _ = h - mgr := session.NewManager(fs.cityBeadStore, fs.sp) - info, err := mgr.CreateAliasedNamedWithTransport( - context.Background(), - "myrig/worker", - "", - "myrig/worker", - "Historic Worker", - "claude", - t.TempDir(), - "claude", - "", - nil, - session.ProviderResume{}, - runtime.Config{}, - ) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) + info, err := mgr.CreateSession( + context.Background(), session.CreateOptions{Alias: "myrig/worker", ExplicitName: "", Template: "myrig/worker", Title: "Historic Worker", Command: "claude", WorkDir: t.TempDir(), Provider: "claude", Transport: "", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { - t.Fatalf("CreateNamedWithTransport: %v", err) + t.Fatalf("CreateSessionNamedWithTransport: %v", err) } if err := mgr.Close(info.ID); err != nil { t.Fatalf("Close: %v", err) @@ -5321,14 +5300,14 @@ func TestHandleSessionTranscriptUsesSessionKey(t *testing.T) { _ = h srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -5369,14 +5348,14 @@ func TestHandleSessionTranscriptClosedSession(t *testing.T) { _ = h srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -5413,14 +5392,14 @@ func TestHandleSessionTranscriptAfterCursor(t *testing.T) { _ = h srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -5463,14 +5442,14 @@ func TestHandleSessionTranscriptAfterCursorRaw(t *testing.T) { _ = h srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -5508,14 +5487,14 @@ func TestHandleSessionTranscriptBeforeAndAfterExclusive(t *testing.T) { _ = h srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -5541,14 +5520,14 @@ func TestHandleSessionTranscriptAfterCursorNotFound(t *testing.T) { _ = h srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -5867,10 +5846,10 @@ func TestHandleSessionMessageRejectsClosedNamedSession(t *testing.T) { h := newTestCityHandlerWith(t, fs, srv) _ = h - mgr := session.NewManager(fs.cityBeadStore, fs.sp) - info, err := mgr.CreateNamedWithTransport(context.Background(), "sky", "myrig/worker", "Sky", "claude", t.TempDir(), "claude", "", nil, session.ProviderResume{}, runtime.Config{}) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{ExplicitName: "sky", Template: "myrig/worker", Title: "Sky", Command: "claude", WorkDir: t.TempDir(), Provider: "claude", Transport: "", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { - t.Fatalf("CreateNamedWithTransport: %v", err) + t.Fatalf("CreateSessionNamedWithTransport: %v", err) } if err := mgr.Close(info.ID); err != nil { t.Fatalf("Close: %v", err) @@ -5921,14 +5900,14 @@ func TestHandleSessionStreamSSEHeaders(t *testing.T) { _ = h srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -5965,8 +5944,8 @@ func TestHandleSessionStreamStoppedWithoutOutputReturnsNotFound(t *testing.T) { _ = h srv.sessionLogSearchPaths = []string{t.TempDir()} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) - info, err := mgr.Create(context.Background(), "default", "No Output", "echo test", t.TempDir(), "test", nil, session.ProviderResume{}, runtime.Config{}) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "default", Title: "No Output", Command: "echo test", WorkDir: t.TempDir(), Provider: "test", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -5989,8 +5968,8 @@ func TestHandleSessionStreamRawStoppedWithoutOutputReturnsNotFound(t *testing.T) h := newTestCityHandlerWith(t, fs, srv) srv.sessionLogSearchPaths = []string{t.TempDir()} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) - info, err := mgr.Create(context.Background(), "default", "No Output", "echo test", t.TempDir(), "test", nil, session.ProviderResume{}, runtime.Config{}) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "default", Title: "No Output", Command: "echo test", WorkDir: t.TempDir(), Provider: "test", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -6012,8 +5991,8 @@ func TestLegacySessionStreamRawStoppedWithoutOutputReturnsNotFound(t *testing.T) srv := New(fs) srv.sessionLogSearchPaths = []string{t.TempDir()} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) - info, err := mgr.Create(context.Background(), "default", "No Output", "echo test", t.TempDir(), "test", nil, session.ProviderResume{}, runtime.Config{}) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "default", Title: "No Output", Command: "echo test", WorkDir: t.TempDir(), Provider: "test", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -6038,14 +6017,14 @@ func TestHandleSessionStreamClosedSessionReturnsSnapshot(t *testing.T) { _ = h srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -6090,14 +6069,14 @@ func TestHandleSessionStreamStoppedSessionCommitsStatusHeaders(t *testing.T) { srv.sessionLogSearchPaths = []string{searchBase} h := newTestCityHandlerWith(t, fs, srv) - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -6143,16 +6122,16 @@ func TestHandleSessionStreamClosedNamedSessionReturnsSnapshot(t *testing.T) { _ = h srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.CreateNamedWithTransport(context.Background(), "sky", "myrig/worker", "Chat", "claude", workDir, "claude", "", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{ExplicitName: "sky", Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Transport: "", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { - t.Fatalf("CreateNamedWithTransport: %v", err) + t.Fatalf("CreateSessionNamedWithTransport: %v", err) } writeNamedSessionJSONL(t, searchBase, workDir, info.SessionKey+".jsonl", `{"uuid":"1","parentUuid":"","type":"user","message":"{\"role\":\"user\",\"content\":\"hello\"}","timestamp":"2025-01-01T00:00:00Z"}`, @@ -6189,14 +6168,14 @@ func TestStreamSessionTranscriptHistoryDoesNotSkipTurnsAcrossCompactionBoundarie searchBase := t.TempDir() srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -6270,14 +6249,14 @@ func TestStreamSessionTranscriptHistoryReloadsChangesWrittenAfterInitialHistory( searchBase := t.TempDir() srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -6359,8 +6338,8 @@ func TestCityScopedSessionStreamReloadsRotatedGeminiTranscriptAcrossRestart(t *t t.Fatalf("chtimes(first transcript): %v", err) } - mgr := session.NewManager(fs.cityBeadStore, fs.sp) - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "gemini", workDir, "gemini", nil, session.ProviderResume{}, runtime.Config{}) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "gemini", WorkDir: workDir, Provider: "gemini", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -6439,8 +6418,8 @@ func TestCityScopedSessionStreamFollowsRotatedGeminiTranscriptAfterWake(t *testi t.Fatalf("chtimes(first transcript): %v", err) } - mgr := session.NewManager(fs.cityBeadStore, fs.sp) - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "gemini", workDir, "gemini", nil, session.ProviderResume{}, runtime.Config{}) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "gemini", WorkDir: workDir, Provider: "gemini", Env: nil, Resume: session.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -6507,14 +6486,14 @@ func TestHandleSessionStreamWorkerOperationEventWakesTranscriptReload(t *testing srv := New(fs) srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -6577,14 +6556,14 @@ func TestHandleSessionStreamRawWorkerOperationEventWakesTranscriptReload(t *test srv := New(fs) srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -6650,14 +6629,14 @@ func TestHandleSessionStreamRawStallEmitsPendingWithoutTranscriptGrowth(t *testi sessionStreamPendingStallTimeout = prevStallTimeout }() - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -6710,14 +6689,14 @@ func TestHandleSessionStreamRawStallEmitsPendingEventOnCityRoute(t *testing.T) { sessionStreamPendingStallTimeout = prevStallTimeout }() - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -6762,14 +6741,14 @@ func TestHandleSessionStreamRawRunningSessionWithoutTranscriptOpensImmediately(t srv := New(fs) h := newTestCityHandlerWith(t, fs, srv) - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -6801,14 +6780,14 @@ func TestHandleSessionStreamTranscriptWriteWakesWithoutPolling(t *testing.T) { srv := New(fs) srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -6865,14 +6844,14 @@ func TestHandleSessionStreamConversationFiltersNonDisplayEntries(t *testing.T) { srv := New(fs) srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -6905,14 +6884,14 @@ func TestHandleSessionStreamConversationRedactsThinkingText(t *testing.T) { srv := New(fs) srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -6943,14 +6922,14 @@ func TestHandleSessionStreamRawUsesLatestCompactionTail(t *testing.T) { srv := New(fs) srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -6986,14 +6965,14 @@ func TestHandleSessionTranscriptRawIncludesAllTypes(t *testing.T) { _ = h srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -7035,14 +7014,14 @@ func TestHandleSessionTranscriptRawIncludesCodexCustomToolCalls(t *testing.T) { _ = h srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "codex", workDir, "codex", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "codex", WorkDir: workDir, Provider: "codex", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -7099,14 +7078,14 @@ func TestHandleSessionTranscriptConversationIncludesCodexErrorFrame(t *testing.T _ = h srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "codex", workDir, "codex", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "codex", WorkDir: workDir, Provider: "codex", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -7155,14 +7134,14 @@ func TestHandleSessionStreamConversationIncludesCodexErrorFrame(t *testing.T) { srv := New(fs) srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Chat", "codex", workDir, "codex", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "codex", WorkDir: workDir, Provider: "codex", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -7200,14 +7179,14 @@ func TestHandleSessionGetActivity(t *testing.T) { _ = h srv.sessionLogSearchPaths = []string{searchBase} - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) resume := session.ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", } workDir := t.TempDir() - info, err := mgr.Create(context.Background(), "myrig/worker", "Activity Test", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Activity Test", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -7488,7 +7467,7 @@ func TestHandleSessionKillClosedSessionIsOK(t *testing.T) { h := newTestCityHandler(t, fs) info := createTestSession(t, fs.cityBeadStore, fs.sp, "kill-closed-test") - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) if err := mgr.Close(info.ID); err != nil { t.Fatalf("Close: %v", err) } @@ -7532,7 +7511,7 @@ func TestHandleSessionMessageQueuesWhenSuspended(t *testing.T) { h := newTestCityHandlerWith(t, fs, srv) info := createTestSession(t, fs.cityBeadStore, fs.sp, "queue-test") - mgr := session.NewManager(fs.cityBeadStore, fs.sp) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) if err := mgr.Suspend(info.ID); err != nil { t.Fatalf("Suspend: %v", err) } diff --git a/internal/api/session_manager.go b/internal/api/session_manager.go index afea441a87..ad92b0f755 100644 --- a/internal/api/session_manager.go +++ b/internal/api/session_manager.go @@ -11,15 +11,15 @@ import ( func (s *Server) sessionManager(store beads.Store) *session.Manager { cfg := s.state.Config() if cfg == nil { - return session.NewManagerWithCityPath(store, s.state.SessionProvider(), s.state.CityPath()) + return session.NewManagerWithOptions(store, s.state.SessionProvider(), session.WithCityPath(s.state.CityPath())) } - return session.NewManagerWithTransportPolicyResolverAndCityPath( + return session.NewManagerWithOptions( store, s.state.SessionProvider(), - s.state.CityPath(), - func(template, provider string) (string, bool) { + session.WithCityPath(s.state.CityPath()), + session.WithTransportPolicyResolver(func(template, provider string) (string, bool) { return configuredSessionTransportResolution(cfg, template, provider) - }, + }), ) } diff --git a/internal/api/session_resolution.go b/internal/api/session_resolution.go index d4547f251f..1203028f51 100644 --- a/internal/api/session_resolution.go +++ b/internal/api/session_resolution.go @@ -346,21 +346,20 @@ func (s *Server) materializeNamedSessionWithContext(ctx context.Context, store b return err } var createErr error - info, createErr = mgr.CreateAliasedNamedWithTransportAndMetadata( - ctx, - spec.Identity, - spec.SessionName, - qualifiedTemplate, - spec.Identity, - launchCommand.Command, - workDir, - resolved.Name, - transport, - sessionEnv, - resume, - hints, - extraMeta, - ) + info, createErr = mgr.CreateSession(ctx, session.CreateOptions{ + Alias: spec.Identity, + ExplicitName: spec.SessionName, + Template: qualifiedTemplate, + Title: spec.Identity, + Command: launchCommand.Command, + WorkDir: workDir, + Provider: resolved.Name, + Transport: transport, + Env: sessionEnv, + Resume: resume, + Hints: hints, + ExtraMeta: extraMeta, + }) return createErr }) if err == nil { diff --git a/internal/api/session_response_wire_test.go b/internal/api/session_response_wire_test.go index 5dabccefb9..a6521b0f5e 100644 --- a/internal/api/session_response_wire_test.go +++ b/internal/api/session_response_wire_test.go @@ -75,7 +75,7 @@ func TestGetWithPersistedResponseWireByteIdentical(t *testing.T) { b := b t.Run(b.ID, func(t *testing.T) { store := beads.NewMemStoreFrom(1, []beads.Bead{b}, nil) - mgr := session.NewManager(store, runtime.NewFake()) + mgr := session.NewManagerWithOptions(store, runtime.NewFake()) // Golden: the pre-S3 double-read. mgr.Get for the runtime-enriched // Info, then a separate store.Get projected to PersistedResponse. diff --git a/internal/api/worker_factory_test.go b/internal/api/worker_factory_test.go index cfbc4656b2..7a1d7ad4f1 100644 --- a/internal/api/worker_factory_test.go +++ b/internal/api/worker_factory_test.go @@ -890,17 +890,8 @@ func TestWorkerFactorySessionByIDUsesResolvedTemplateRuntime(t *testing.T) { } srv := New(fs) - mgr := session.NewManager(fs.cityBeadStore, fs.sp) - info, err := mgr.CreateBeadOnly( - "myrig/worker", - "Chat", - "", - t.TempDir(), - "", - "", - nil, - session.ProviderResume{SessionIDFlag: "--stale-session-id"}, - ) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{BeadOnly: true, Template: "myrig/worker", Title: "Chat", Command: "", WorkDir: t.TempDir(), Provider: "", Transport: "", Resume: session.ProviderResume{SessionIDFlag: "--stale-session-id"}}) if err != nil { t.Fatalf("CreateBeadOnly: %v", err) } @@ -942,17 +933,8 @@ func TestWorkerFactorySessionByIDPreservesStoredResolvedCommand(t *testing.T) { } srv := New(fs) - mgr := session.NewManager(fs.cityBeadStore, fs.sp) - info, err := mgr.CreateBeadOnly( - "myrig/worker", - "Chat", - "/bin/echo --composed", - t.TempDir(), - "resolved-worker", - "", - nil, - session.ProviderResume{SessionIDFlag: "--stale-session-id"}, - ) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{BeadOnly: true, Template: "myrig/worker", Title: "Chat", Command: "/bin/echo --composed", WorkDir: t.TempDir(), Provider: "resolved-worker", Transport: "", Resume: session.ProviderResume{SessionIDFlag: "--stale-session-id"}}) if err != nil { t.Fatalf("CreateBeadOnly: %v", err) } @@ -990,22 +972,13 @@ func TestWorkerFactorySessionByIDUsesResolvedCommandAndResumeSettingsOnResume(t } srv := New(fs) - mgr := session.NewManager(fs.cityBeadStore, fs.sp) - info, err := mgr.Create( - context.Background(), - "myrig/worker", - "Chat", - "legacy-agent", - t.TempDir(), - "resolved-worker", - nil, - session.ProviderResume{ + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) + info, err := mgr.CreateSession( + context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "legacy-agent", WorkDir: t.TempDir(), Provider: "resolved-worker", Env: nil, Resume: session.ProviderResume{ ResumeFlag: "--old-resume", ResumeStyle: "flag", SessionIDFlag: "--session-id-resolved", - }, - runtime.Config{}, - ) + }, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1044,21 +1017,12 @@ func TestWorkerFactorySessionByIDAppliesTemplateOverridesToExplicitResumeCommand fs.cfg.Providers["resolved-worker"] = spec srv := New(fs) - mgr := session.NewManager(fs.cityBeadStore, fs.sp) - info, err := mgr.Create( - context.Background(), - "myrig/worker", - "Chat", - "/bin/echo --skip-permissions", - t.TempDir(), - "resolved-worker", - nil, - session.ProviderResume{ + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) + info, err := mgr.CreateSession( + context.Background(), session.CreateOptions{Template: "myrig/worker", Title: "Chat", Command: "/bin/echo --skip-permissions", WorkDir: t.TempDir(), Provider: "resolved-worker", Env: nil, Resume: session.ProviderResume{ ResumeCommand: "/bin/echo resume {{.SessionKey}} --skip-permissions", SessionIDFlag: "--session-id", - }, - runtime.Config{}, - ) + }, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1105,17 +1069,8 @@ func TestWorkerFactoryHandleForTargetUsesResolvedTemplateRuntimeForSessionMeta(t } srv := New(fs) - mgr := session.NewManager(fs.cityBeadStore, fs.sp) - info, err := mgr.CreateBeadOnly( - "myrig/worker", - "Chat", - "", - t.TempDir(), - "", - "", - nil, - session.ProviderResume{SessionIDFlag: "--stale-session-id"}, - ) + mgr := session.NewManagerWithOptions(fs.cityBeadStore, fs.sp) + info, err := mgr.CreateSession(context.Background(), session.CreateOptions{BeadOnly: true, Template: "myrig/worker", Title: "Chat", Command: "", WorkDir: t.TempDir(), Provider: "", Transport: "", Resume: session.ProviderResume{SessionIDFlag: "--stale-session-id"}}) if err != nil { t.Fatalf("CreateBeadOnly: %v", err) } diff --git a/internal/session/create_options.go b/internal/session/create_options.go new file mode 100644 index 0000000000..91d520bc46 --- /dev/null +++ b/internal/session/create_options.go @@ -0,0 +1,40 @@ +package session + +import "github.com/gastownhall/gascity/internal/runtime" + +// CreateOptions is the single, field-named description of a session to create +// through Manager.CreateSession. It replaces the telescoping family of +// positional Create* worker parameters: every optional knob is a named field, +// so a transposed template/title or provider/transport is unrepresentable at +// compile time. +// +// When BeadOnly is true the session bead is created in the "start-pending" +// state without starting a runtime process (the reconciler starts it later); +// Env and Hints are ignored on that path. Otherwise the runtime session is +// started immediately. +type CreateOptions struct { + Alias string + ExplicitName string + Template string + Title string + Command string + WorkDir string + Provider string + Transport string + Env map[string]string + Resume ProviderResume + Hints runtime.Config + ExtraMeta map[string]string + BeadOnly bool +} + +// defaultSessionOrigin returns the session_origin to record when ExtraMeta does +// not set one explicitly. Started sessions default to "manual"; bead-only +// (deferred) sessions default to "ephemeral". This reproduces the per-path +// defaulting that the retired Create* wrappers each applied. +func (o CreateOptions) defaultSessionOrigin() string { + if o.BeadOnly { + return "ephemeral" + } + return "manual" +} diff --git a/internal/session/create_options_test.go b/internal/session/create_options_test.go new file mode 100644 index 0000000000..fd37d57ebd --- /dev/null +++ b/internal/session/create_options_test.go @@ -0,0 +1,185 @@ +package session + +import ( + "context" + "testing" + + "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/runtime" +) + +func TestCreateOptionsDefaultSessionOrigin(t *testing.T) { + if got := (CreateOptions{}).defaultSessionOrigin(); got != "manual" { + t.Errorf("started defaultSessionOrigin = %q, want %q", got, "manual") + } + if got := (CreateOptions{BeadOnly: true}).defaultSessionOrigin(); got != "ephemeral" { + t.Errorf("bead-only defaultSessionOrigin = %q, want %q", got, "ephemeral") + } +} + +func TestCreateSessionStartedDefaultsToManualOrigin(t *testing.T) { + store := beads.NewMemStore() + sp := runtime.NewFake() + mgr := NewManagerWithOptions(store, sp) + + info, err := mgr.CreateSession(context.Background(), CreateOptions{ + Template: "helper", + Title: "my chat", + Command: "claude", + WorkDir: "/tmp", + Provider: "claude", + }) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + if info.State != StateActive { + t.Errorf("State = %q, want %q", info.State, StateActive) + } + if !sp.IsRunning(info.SessionName) { + t.Error("runtime session not started") + } + b, err := store.Get(info.ID) + if err != nil { + t.Fatalf("store.Get: %v", err) + } + if b.Metadata["session_origin"] != "manual" { + t.Errorf("session_origin = %q, want %q", b.Metadata["session_origin"], "manual") + } +} + +func TestCreateSessionBeadOnlyDefaultsToEphemeralOrigin(t *testing.T) { + store := beads.NewMemStore() + sp := runtime.NewFake() + mgr := NewManagerWithOptions(store, sp) + + info, err := mgr.CreateSession(context.Background(), CreateOptions{ + BeadOnly: true, + Template: "helper", + Title: "queued", + Command: "claude", + WorkDir: "/tmp", + Provider: "claude", + }) + if err != nil { + t.Fatalf("CreateSession(bead-only): %v", err) + } + if info.State != StateStartPending { + t.Errorf("State = %q, want %q", info.State, StateStartPending) + } + if sp.IsRunning(info.SessionName) { + t.Error("bead-only create must not start a runtime session") + } + b, err := store.Get(info.ID) + if err != nil { + t.Fatalf("store.Get: %v", err) + } + if b.Metadata["session_origin"] != "ephemeral" { + t.Errorf("session_origin = %q, want %q", b.Metadata["session_origin"], "ephemeral") + } +} + +func TestCreateSessionExtraMetaOverridesOriginDefault(t *testing.T) { + store := beads.NewMemStore() + sp := runtime.NewFake() + mgr := NewManagerWithOptions(store, sp) + + info, err := mgr.CreateSession(context.Background(), CreateOptions{ + Template: "helper", + Command: "claude", + WorkDir: "/tmp", + Provider: "claude", + ExtraMeta: map[string]string{"session_origin": "named"}, + }) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + b, err := store.Get(info.ID) + if err != nil { + t.Fatalf("store.Get: %v", err) + } + if b.Metadata["session_origin"] != "named" { + t.Errorf("session_origin = %q, want explicit %q", b.Metadata["session_origin"], "named") + } +} + +// TestCreateSessionFieldNamedSpecMapsCorrectly guards against argument +// transposition: alias, explicit name, and transport land on their own fields. +func TestCreateSessionFieldNamedSpecMapsCorrectly(t *testing.T) { + store := beads.NewMemStore() + sp := runtime.NewFake() + mgr := NewManagerWithOptions(store, sp) + + info, err := mgr.CreateSession(context.Background(), CreateOptions{ + Alias: "sky", + ExplicitName: "myrig--worker", + Template: "helper", + Title: "Sky", + Command: "claude", + WorkDir: "/tmp", + Provider: "claude", + Transport: "acp", + }) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + b, err := store.Get(info.ID) + if err != nil { + t.Fatalf("store.Get: %v", err) + } + if b.Metadata["alias"] != "sky" { + t.Errorf("alias = %q, want %q", b.Metadata["alias"], "sky") + } + if b.Metadata["session_name"] != "myrig--worker" { + t.Errorf("session_name = %q, want %q", b.Metadata["session_name"], "myrig--worker") + } + if b.Metadata["transport"] != "acp" { + t.Errorf("transport = %q, want %q", b.Metadata["transport"], "acp") + } + if b.Metadata["template"] != "helper" { + t.Errorf("template = %q, want %q", b.Metadata["template"], "helper") + } +} + +// TestCreateSessionMatchesLegacyWrapper proves the collapsed CreateSession +// default coincides with the legacy started-wrapper's hardcoded +// session_origin=manual. The retired Create/CreateNamed* wrappers stamped +// "manual" literally; the collapsed path instead relies on +// defaultSessionOrigin(). Locking those two together means a future change to +// the default that diverged from the historical hardcoded value would fail +// here, rather than silently altering started-session provenance. +func TestCreateSessionMatchesLegacyWrapper(t *testing.T) { + viaDefault := createOriginMetadata(t, func(mgr *Manager) (Info, error) { + // No session_origin in ExtraMeta: exercise the collapsed default path. + return mgr.CreateSession(context.Background(), CreateOptions{ + Template: "helper", Title: "chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", + }) + }) + viaLegacyExplicit := createOriginMetadata(t, func(mgr *Manager) (Info, error) { + // The value the retired started wrappers hardcoded. + return mgr.CreateSession(context.Background(), CreateOptions{ + Template: "helper", Title: "chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", + ExtraMeta: map[string]string{"session_origin": "manual"}, + }) + }) + if viaDefault != viaLegacyExplicit { + t.Errorf("session_origin parity mismatch: default=%q legacy-explicit=%q", viaDefault, viaLegacyExplicit) + } + if viaDefault != "manual" { + t.Errorf("collapsed default session_origin = %q, want legacy %q", viaDefault, "manual") + } +} + +func createOriginMetadata(t *testing.T, create func(*Manager) (Info, error)) string { + t.Helper() + store := beads.NewMemStore() + mgr := NewManagerWithOptions(store, runtime.NewFake()) + info, err := create(mgr) + if err != nil { + t.Fatalf("create: %v", err) + } + b, err := store.Get(info.ID) + if err != nil { + t.Fatalf("store.Get: %v", err) + } + return b.Metadata["session_origin"] +} diff --git a/internal/session/get_persisted_response_test.go b/internal/session/get_persisted_response_test.go index e7a588841b..a051b6e590 100644 --- a/internal/session/get_persisted_response_test.go +++ b/internal/session/get_persisted_response_test.go @@ -27,7 +27,7 @@ func TestGetWithPersistedResponse(t *testing.T) { "real_world_app_project_id": "proj-9", }) store := beads.NewMemStoreFrom(1, []beads.Bead{b}, nil) - mgr := NewManager(store, runtime.NewFake()) + mgr := NewManagerWithOptions(store, runtime.NewFake()) info, pr, err := mgr.GetWithPersistedResponse("s-pr-1") if err != nil { @@ -60,7 +60,7 @@ func TestGetWithPersistedResponse(t *testing.T) { // error mgr.Get would return. func TestGetWithPersistedResponseNotFound(t *testing.T) { store := beads.NewMemStore() - mgr := NewManager(store, runtime.NewFake()) + mgr := NewManagerWithOptions(store, runtime.NewFake()) if _, _, err := mgr.GetWithPersistedResponse("missing"); err == nil { t.Fatal("GetWithPersistedResponse(missing): want error, got nil") } diff --git a/internal/session/manager.go b/internal/session/manager.go index f3060a9b68..f230fa29f4 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -567,65 +567,36 @@ func (m *Manager) routeACPIfNeeded(provider, transport, sessName string) func() return func() { router.Unroute(sessName) } } -// NewManager creates a Manager backed by the given bead store and session provider. -func NewManager(store beads.Store, sp runtime.Provider) *Manager { - return &Manager{store: store, sp: sp} -} - -// NewManagerWithTransportResolver creates a Manager that can infer session -// transport from template or provider config when older beads do not have -// transport metadata. -func NewManagerWithTransportResolver(store beads.Store, sp runtime.Provider, resolver func(template, provider string) string) *Manager { - return &Manager{ - store: store, - sp: sp, - transportResolver: func(template, provider string) transportResolution { +// ManagerOption configures an optional Manager capability. It is the single +// knob form behind NewManagerWithOptions; the named NewManager* constructors +// are thin presets over it. +type ManagerOption func(*Manager) + +// WithCityPath lets the Manager persist deferred submits into the city's +// nudge queue rooted at cityPath. +func WithCityPath(cityPath string) ManagerOption { + return func(m *Manager) { m.cityPath = cityPath } +} + +// WithTransportResolver lets the Manager infer session transport from template +// or provider config when older beads do not have transport metadata. +func WithTransportResolver(resolver func(template, provider string) string) ManagerOption { + return func(m *Manager) { + m.transportResolver = func(template, provider string) transportResolution { if resolver == nil { return transportResolution{} } return transportResolution{transport: resolver(template, provider)} - }, + } } } -// NewManagerWithCityPath creates a Manager that can persist deferred submits -// into the city's nudge queue. -func NewManagerWithCityPath(store beads.Store, sp runtime.Provider, cityPath string) *Manager { - return &Manager{store: store, sp: sp, cityPath: cityPath} -} - -// NewManagerWithTransportResolverAndCityPath creates a Manager that can infer -// session transport from template or provider config and persist deferred -// submits into the city's nudge queue. -func NewManagerWithTransportResolverAndCityPath(store beads.Store, sp runtime.Provider, cityPath string, resolver func(template, provider string) string) *Manager { - return &Manager{ - store: store, - sp: sp, - cityPath: cityPath, - transportResolver: func(template, provider string) transportResolution { - if resolver == nil { - return transportResolution{} - } - return transportResolution{transport: resolver(template, provider)} - }, - } -} - -// NewManagerWithTransportPolicyResolverAndCityPath creates a Manager that can -// infer transport from config and, when the resolver marks it safe, continue -// using that transport for stopped legacy sessions without persisted -// transport metadata. -func NewManagerWithTransportPolicyResolverAndCityPath( - store beads.Store, - sp runtime.Provider, - cityPath string, - resolver func(template, provider string) (string, bool), -) *Manager { - return &Manager{ - store: store, - sp: sp, - cityPath: cityPath, - transportResolver: func(template, provider string) transportResolution { +// WithTransportPolicyResolver lets the Manager infer transport from config and, +// when the resolver marks it safe, continue using that transport for stopped +// legacy sessions without persisted transport metadata. +func WithTransportPolicyResolver(resolver func(template, provider string) (string, bool)) ManagerOption { + return func(m *Manager) { + m.transportResolver = func(template, provider string) transportResolution { if resolver == nil { return transportResolution{} } @@ -634,45 +605,42 @@ func NewManagerWithTransportPolicyResolverAndCityPath( transport: transport, allowStoppedFallback: allowStoppedFallback, } - }, + } } } -// Create creates a new chat session bead and starts the runtime session. -// The command is the full provider command to execute (e.g., "claude --dangerously-skip-permissions"). -// The resume parameter carries provider resume capabilities; if the provider -// supports SessionIDFlag, a UUID session key is generated and injected. -// The caller is responsible for attaching after Create returns. -func (m *Manager) Create(ctx context.Context, template, title, command, workDir, provider string, env map[string]string, resume ProviderResume, hints runtime.Config) (Info, error) { - return m.CreateAliasedNamedWithTransportAndMetadata(ctx, "", "", template, title, command, workDir, provider, "", env, resume, hints, map[string]string{ - "session_origin": "manual", - }) +// NewManagerWithOptions creates a Manager backed by the given bead store and +// session provider, applying any capability options. It is the canonical +// constructor; the named NewManager* variants below are one-line presets. +func NewManagerWithOptions(store beads.Store, sp runtime.Provider, opts ...ManagerOption) *Manager { + m := &Manager{store: store, sp: sp} + for _, opt := range opts { + opt(m) + } + return m } -// CreateWithTransport creates a new chat session bead and starts the runtime -// session, preserving the transport override separately from the provider name -// so ACP-routed sessions can be resumed correctly. -func (m *Manager) CreateWithTransport(ctx context.Context, template, title, command, workDir, provider, transport string, env map[string]string, resume ProviderResume, hints runtime.Config) (Info, error) { - return m.CreateAliasedNamedWithTransportAndMetadata(ctx, "", "", template, title, command, workDir, provider, transport, env, resume, hints, map[string]string{ - "session_origin": "manual", - }) +// CreateSession is the single entry point for creating a session. It reads a +// field-named CreateOptions and either starts the runtime immediately or, when +// spec.BeadOnly is set, creates a start-pending bead for the reconciler to +// start later. +func (m *Manager) CreateSession(ctx context.Context, spec CreateOptions) (Info, error) { + if spec.BeadOnly { + return m.createBeadOnly(spec) + } + return m.createStarted(ctx, spec) } -// CreateAliasedNamedWithTransport creates a new chat session bead with an -// optional public alias and optional explicit runtime session_name. -func (m *Manager) CreateAliasedNamedWithTransport(ctx context.Context, alias, explicitName, template, title, command, workDir, provider, transport string, env map[string]string, resume ProviderResume, hints runtime.Config) (Info, error) { - return m.createAliasedNamedWithTransport(ctx, alias, explicitName, template, title, command, workDir, provider, transport, env, resume, hints, map[string]string{ - "session_origin": "manual", - }) -} +func (m *Manager) createStarted(ctx context.Context, spec CreateOptions) (Info, error) { + alias, explicitName := spec.Alias, spec.ExplicitName + template, title := spec.Template, spec.Title + command, workDir := spec.Command, spec.WorkDir + provider, transport := spec.Provider, spec.Transport + env := spec.Env + resume := spec.Resume + hints := spec.Hints + extraMeta := spec.ExtraMeta -// CreateAliasedNamedWithTransportAndMetadata creates a new chat session bead -// with additional metadata published atomically at bead creation time. -func (m *Manager) CreateAliasedNamedWithTransportAndMetadata(ctx context.Context, alias, explicitName, template, title, command, workDir, provider, transport string, env map[string]string, resume ProviderResume, hints runtime.Config, extraMeta map[string]string) (Info, error) { - return m.createAliasedNamedWithTransport(ctx, alias, explicitName, template, title, command, workDir, provider, transport, env, resume, hints, extraMeta) -} - -func (m *Manager) createAliasedNamedWithTransport(ctx context.Context, alias, explicitName, template, title, command, workDir, provider, transport string, env map[string]string, resume ProviderResume, hints runtime.Config, extraMeta map[string]string) (Info, error) { alias, err := ValidateAlias(alias) if err != nil { return Info{}, err @@ -742,7 +710,7 @@ func (m *Manager) createAliasedNamedWithTransport(ctx context.Context, alias, ex meta[k] = v } if meta["session_origin"] == "" { - meta["session_origin"] = "manual" + meta["session_origin"] = spec.defaultSessionOrigin() } createdBead, createErr := m.store.Create(beads.Bead{ Title: title, @@ -891,18 +859,6 @@ func (m *Manager) confirmStartedRuntimeMetadata(id string, b *beads.Bead) error return nil } -// CreateNamedWithTransport creates a new chat session bead with an optional -// explicit session_name and starts the runtime session. -// -// WARNING: withSessionNameReservationLock only serializes callers inside this -// process. Callers MUST also hold WithCitySessionNameLock(cityPath, explicitName) -// when explicitName is non-empty so duplicate names cannot race across processes. -func (m *Manager) CreateNamedWithTransport(ctx context.Context, explicitName, template, title, command, workDir, provider, transport string, env map[string]string, resume ProviderResume, hints runtime.Config) (Info, error) { - return m.CreateAliasedNamedWithTransportAndMetadata(ctx, "", explicitName, template, title, command, workDir, provider, transport, env, resume, hints, map[string]string{ - "session_origin": "manual", - }) -} - func runtimeSessionMatchesBead(sp runtime.Provider, sessionName, beadID, instanceToken string) bool { if sp == nil { return false @@ -924,30 +880,14 @@ func runtimeSessionMatchesBead(sp runtime.Provider, sessionName, beadID, instanc return strings.TrimSpace(liveToken) == instanceToken } -// CreateBeadOnly creates a session bead without starting the runtime process. -// The bead is created with state "start-pending" — the controller's -// reconciler will detect it in buildDesiredState and start the process on its -// next tick. -// -// This is the Phase 2 path: CLI creates intent (bead), reconciler executes. -func (m *Manager) CreateBeadOnly(template, title, command, workDir, provider, transport string, env map[string]string, resume ProviderResume) (Info, error) { - return m.CreateBeadOnlyNamed("", template, title, command, workDir, provider, transport, env, resume) -} - -// CreateAliasedBeadOnlyNamed creates a session bead without starting the -// runtime process, preserving an optional public alias and explicit runtime -// session_name for the reconciler. -func (m *Manager) CreateAliasedBeadOnlyNamed(alias, explicitName, template, title, command, workDir, provider, transport string, _ map[string]string, resume ProviderResume) (Info, error) { - return m.createAliasedBeadOnlyNamed(alias, explicitName, template, title, command, workDir, provider, transport, resume, nil) -} - -// CreateAliasedBeadOnlyNamedWithMetadata creates a session bead without -// starting the runtime process, publishing extra metadata atomically. -func (m *Manager) CreateAliasedBeadOnlyNamedWithMetadata(alias, explicitName, template, title, command, workDir, provider, transport string, resume ProviderResume, extraMeta map[string]string) (Info, error) { - return m.createAliasedBeadOnlyNamed(alias, explicitName, template, title, command, workDir, provider, transport, resume, extraMeta) -} +func (m *Manager) createBeadOnly(spec CreateOptions) (Info, error) { + alias, explicitName := spec.Alias, spec.ExplicitName + template, title := spec.Template, spec.Title + command, workDir := spec.Command, spec.WorkDir + provider, transport := spec.Provider, spec.Transport + resume := spec.Resume + extraMeta := spec.ExtraMeta -func (m *Manager) createAliasedBeadOnlyNamed(alias, explicitName, template, title, command, workDir, provider, transport string, resume ProviderResume, extraMeta map[string]string) (Info, error) { alias, err := ValidateAlias(alias) if err != nil { return Info{}, err @@ -1013,7 +953,7 @@ func (m *Manager) createAliasedBeadOnlyNamed(alias, explicitName, template, titl meta[k] = v } if meta["session_origin"] == "" { - meta["session_origin"] = "ephemeral" + meta["session_origin"] = spec.defaultSessionOrigin() } createdBead, createErr := m.store.Create(beads.Bead{ Title: title, @@ -1051,16 +991,6 @@ func (m *Manager) createAliasedBeadOnlyNamed(alias, explicitName, template, titl return info, nil } -// CreateBeadOnlyNamed creates a session bead without starting the runtime -// process, preserving an optional explicit session_name for the reconciler. -// -// WARNING: withSessionNameReservationLock only serializes callers inside this -// process. Callers MUST also hold WithCitySessionNameLock(cityPath, explicitName) -// when explicitName is non-empty so duplicate names cannot race across processes. -func (m *Manager) CreateBeadOnlyNamed(explicitName, template, title, command, workDir, provider, transport string, _ map[string]string, resume ProviderResume) (Info, error) { - return m.CreateAliasedBeadOnlyNamed("", explicitName, template, title, command, workDir, provider, transport, nil, resume) -} - // Attach attaches the user's terminal to the session. If the session is // suspended, it is resumed first using resumeCommand. If the tmux session // died (active bead but no process), it is restarted. diff --git a/internal/session/manager_states_test.go b/internal/session/manager_states_test.go index 9c4dae5cb2..4b0eb77806 100644 --- a/internal/session/manager_states_test.go +++ b/internal/session/manager_states_test.go @@ -46,7 +46,7 @@ func getState(t *testing.T, m *Manager, id string) State { func TestConformance_CreatingState(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - m := NewManager(store, sp) + m := NewManagerWithOptions(store, sp) // Create a bead in creating state. b, err := store.Create(beads.Bead{ @@ -87,7 +87,7 @@ func TestConformance_CreatingState(t *testing.T) { func TestConformance_DrainState(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - m := NewManager(store, sp) + m := NewManagerWithOptions(store, sp) id := createTestSession(t, m, "worker") @@ -128,7 +128,7 @@ func TestConformance_DrainState(t *testing.T) { func TestConformance_QuarantineState(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - m := NewManager(store, sp) + m := NewManagerWithOptions(store, sp) id := createTestSession(t, m, "worker") if err := store.SetMetadata(id, "last_woke_at", time.Now().UTC().Format(time.RFC3339)); err != nil { @@ -157,7 +157,7 @@ func TestConformance_QuarantineState(t *testing.T) { func TestConformance_ArchivedReactivation(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - m := NewManager(store, sp) + m := NewManagerWithOptions(store, sp) id := createTestSession(t, m, "worker") @@ -203,7 +203,7 @@ func TestConformance_IllegalTransitionDraining(t *testing.T) { // Drain puts a session in Draining; Suspend from Draining is illegal. store := beads.NewMemStore() sp := runtime.NewFake() - m := NewManager(store, sp) + m := NewManagerWithOptions(store, sp) id := createTestSession(t, m, "worker") @@ -241,7 +241,7 @@ func TestConformance_SuspendFailedCreateTearsDownRuntime(t *testing.T) { // with an illegal-transition error that blocks `gc stop` city-wide. store := beads.NewMemStore() sp := runtime.NewFake() - m := NewManager(store, sp) + m := NewManagerWithOptions(store, sp) id := createTestSession(t, m, "dog") b, err := store.Get(id) @@ -276,7 +276,7 @@ func TestConformance_SuspendFailedCreateTearsDownRuntime(t *testing.T) { func TestConformance_QuarantineReactivation(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - m := NewManager(store, sp) + m := NewManagerWithOptions(store, sp) id := createTestSession(t, m, "crasher") diff --git a/internal/session/manager_test.go b/internal/session/manager_test.go index b19ddde04d..5643fd8046 100644 --- a/internal/session/manager_test.go +++ b/internal/session/manager_test.go @@ -228,9 +228,9 @@ func (s waitFailStore) ListByLabel(label string, limit int, opts ...beads.QueryO func TestCreate(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "my chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -319,9 +319,9 @@ func TestCreateKillsUntrackedOrphanBeforeStart(t *testing.T) { IsTracked: false, }}, } - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "my chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -341,9 +341,9 @@ func TestCreateSkipsTrackedRuntimeBeforeStart(t *testing.T) { IsTracked: true, }}, } - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "my chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -364,9 +364,9 @@ func TestCreateSkipsUntrackedRuntimeFromOtherCityBeforeStart(t *testing.T) { IsTracked: false, }}, } - mgr := NewManagerWithCityPath(store, sp, "/tmp/this-city") + mgr := NewManagerWithOptions(store, sp, WithCityPath("/tmp/this-city")) - info, err := mgr.Create(context.Background(), "helper", "my chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -392,9 +392,9 @@ func TestCreateKillsUntrackedOrphanFromSameCityBeforeStartWithNormalizedPath(t * IsTracked: false, }}, } - mgr := NewManagerWithCityPath(store, sp, aliasCity) + mgr := NewManagerWithOptions(store, sp, WithCityPath(aliasCity)) - info, err := mgr.Create(context.Background(), "helper", "my chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -422,9 +422,9 @@ func TestCreateRefusesStartWhenOrphanNotConfirmedDead(t *testing.T) { IsTracked: false, }}, } - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - _, err := mgr.Create(context.Background(), "helper", "my chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + _, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err == nil { t.Fatal("Create succeeded despite an orphan that could not be confirmed dead") } @@ -465,8 +465,8 @@ func seedSuspendedResumeTarget(t *testing.T) (*Manager, *orphanScanProvider, Inf t.Helper() store := beads.NewMemStore() sp := &orphanScanProvider{Fake: runtime.NewFake()} - mgr := NewManager(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", t.TempDir(), "claude", nil, ProviderResume{}, runtime.Config{}) + mgr := NewManagerWithOptions(store, sp) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: t.TempDir(), Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -605,7 +605,7 @@ func TestStartUnwindsACPRouteWhenOrphanNotConfirmedDead(t *testing.T) { store := beads.NewMemStore() sp := &acpOrphanScanProvider{orphanScanProvider: &orphanScanProvider{Fake: runtime.NewFake()}} armUnconfirmedOrphan(sp.orphanScanProvider) - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) b, err := store.Create(beads.Bead{ Type: BeadType, @@ -648,9 +648,9 @@ func TestStartUnwindsACPRouteWhenOrphanNotConfirmedDead(t *testing.T) { func TestCreateWithProviderWithoutProcessScannerStillStarts(t *testing.T) { store := beads.NewMemStore() fake := runtime.NewFake() - mgr := NewManager(store, &providerWithoutProcessScanner{Provider: fake}) + mgr := NewManagerWithOptions(store, &providerWithoutProcessScanner{Provider: fake}) - info, err := mgr.Create(context.Background(), "helper", "my chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -719,9 +719,9 @@ func orphanCleanupPrecedes(lines []string, before int, idExpr string) bool { func TestUpdateTemplateOverridesRejectsRunningSessionUnderLock(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "my chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -734,9 +734,9 @@ func TestUpdateTemplateOverridesRejectsRunningSessionUnderLock(t *testing.T) { func TestUpdateTemplateOverridesRejectsLiveRuntimeEvenWhenStateLooksDormant(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "my chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -752,9 +752,9 @@ func TestUpdateTemplateOverridesRejectsLiveRuntimeEvenWhenStateLooksDormant(t *t func TestUpdateTemplateOverridesAllowsSuspendedSession(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "my chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -781,9 +781,9 @@ func TestUpdateTemplateOverridesAllowsSuspendedSession(t *testing.T) { func TestUpdateTemplateOverridesRejectsRecentWakeInFlight(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "my chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -803,9 +803,9 @@ func TestUpdateTemplateOverridesRejectsRecentWakeInFlight(t *testing.T) { func TestUpdateTemplateOverridesRejectsPendingCreateClaim(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "my chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -825,10 +825,10 @@ func TestUpdateTemplateOverridesRejectsPendingCreateClaim(t *testing.T) { func TestUpdateTemplateOverridesWakeInFlightGraceBoundary(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) mgr.clk = &clock.Fake{Time: time.Date(2030, 1, 1, 12, 0, 0, 0, time.UTC)} - info, err := mgr.Create(context.Background(), "helper", "my chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -861,9 +861,9 @@ func TestUpdateTemplateOverridesWakeInFlightGraceBoundary(t *testing.T) { func TestUpdateTemplateOverridesAllowsOldWakeTimestamp(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "my chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -887,10 +887,10 @@ func TestUpdateTemplateOverridesAllowsOldWakeTimestamp(t *testing.T) { func TestUpdateTemplateOverridesUsesManagerClockForWakeWindow(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) mgr.clk = &clock.Fake{Time: time.Date(2030, 1, 1, 12, 0, 0, 0, time.UTC)} - info, err := mgr.Create(context.Background(), "helper", "my chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -914,10 +914,10 @@ func TestUpdateTemplateOverridesUsesManagerClockForWakeWindow(t *testing.T) { func TestUpdateTemplateOverridesAllowsFailedCreateWithRecentWake(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) mgr.clk = &clock.Fake{Time: time.Date(2030, 1, 1, 12, 0, 0, 0, time.UTC)} - info, err := mgr.Create(context.Background(), "helper", "my chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -943,9 +943,9 @@ func TestUpdateTemplateOverridesAllowsFailedCreateWithRecentWake(t *testing.T) { func TestUpdateTemplateOverridesRepairsMalformedMetadata(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "my chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -968,23 +968,14 @@ func TestUpdateTemplateOverridesRepairsMalformedMetadata(t *testing.T) { func TestCreateConfirmsStartedStateWithoutControllerDriftHash(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) - - info, err := mgr.Create( - context.Background(), - "helper", - "my chat", - "claude", - "/tmp", - "claude", - map[string]string{"BEADS_DIR": "/tmp/beads"}, - ProviderResume{}, - runtime.Config{ + mgr := NewManagerWithOptions(store, sp) + + info, err := mgr.CreateSession( + context.Background(), CreateOptions{Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: map[string]string{"BEADS_DIR": "/tmp/beads"}, Resume: ProviderResume{}, Hints: runtime.Config{ Env: map[string]string{"GC_CITY": "test-city"}, FingerprintExtra: map[string]string{"depends_on": "db"}, SessionLive: []string{"echo live"}, - }, - ) + }, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1013,9 +1004,9 @@ func TestCreateConfirmsStartedStateWithoutControllerDriftHash(t *testing.T) { func TestCreateDefaultsTitleToTemplate(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1028,14 +1019,14 @@ func TestCreateDefaultsTitleToTemplate(t *testing.T) { } } -func TestCreateBeadOnlyDefaultsTitleToTemplate(t *testing.T) { +func TestCreateSessionBeadOnlyDefaultsTitleToTemplate(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.CreateBeadOnly("helper", "", "claude", "/tmp", "claude", "", nil, ProviderResume{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{BeadOnly: true, Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Resume: ProviderResume{}}) if err != nil { - t.Fatalf("CreateBeadOnly: %v", err) + t.Fatalf("CreateSessionBeadOnly: %v", err) } b, err := store.Get(info.ID) if err != nil { @@ -1046,14 +1037,14 @@ func TestCreateBeadOnlyDefaultsTitleToTemplate(t *testing.T) { } } -func TestCreateBeadOnly(t *testing.T) { +func TestCreateSessionBeadOnly(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.CreateBeadOnly("helper", "my chat", "claude", "/tmp", "claude", "", nil, ProviderResume{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{BeadOnly: true, Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Resume: ProviderResume{}}) if err != nil { - t.Fatalf("CreateBeadOnly: %v", err) + t.Fatalf("CreateSessionBeadOnly: %v", err) } if info.Template != "helper" { t.Errorf("Template = %q, want %q", info.Template, "helper") @@ -1092,11 +1083,11 @@ func TestCreateBeadOnly(t *testing.T) { func TestGetSurfacesAgentNameMetadata(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.CreateBeadOnly("helper", "my chat", "claude", "/tmp", "claude", "", nil, ProviderResume{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{BeadOnly: true, Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Resume: ProviderResume{}}) if err != nil { - t.Fatalf("CreateBeadOnly: %v", err) + t.Fatalf("CreateSessionBeadOnly: %v", err) } if err := store.SetMetadata(info.ID, "agent_name", "myrig/helper-adhoc-123"); err != nil { t.Fatalf("SetMetadata(agent_name): %v", err) @@ -1120,11 +1111,11 @@ func TestGetSurfacesAgentNameMetadata(t *testing.T) { func TestGetSurfacesLastNudgeDeliveredAtMetadata(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.CreateBeadOnly("helper", "my chat", "claude", "/tmp", "claude", "", nil, ProviderResume{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{BeadOnly: true, Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Resume: ProviderResume{}}) if err != nil { - t.Fatalf("CreateBeadOnly: %v", err) + t.Fatalf("CreateSessionBeadOnly: %v", err) } stamp := time.Date(2026, 5, 11, 12, 0, 0, 0, time.UTC) @@ -1149,11 +1140,11 @@ func TestGetSurfacesLastNudgeDeliveredAtMetadata(t *testing.T) { func TestGetIgnoresInvalidLastNudgeDeliveredAtMetadata(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.CreateBeadOnly("helper", "my chat", "claude", "/tmp", "claude", "", nil, ProviderResume{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{BeadOnly: true, Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Resume: ProviderResume{}}) if err != nil { - t.Fatalf("CreateBeadOnly: %v", err) + t.Fatalf("CreateSessionBeadOnly: %v", err) } if err := store.SetMetadata(info.ID, MetadataLastNudgeDeliveredAt, "not-a-timestamp"); err != nil { t.Fatalf("SetMetadata: %v", err) @@ -1168,14 +1159,14 @@ func TestGetIgnoresInvalidLastNudgeDeliveredAtMetadata(t *testing.T) { } } -func TestCreateNamedWithTransport_UsesExplicitSessionName(t *testing.T) { +func TestCreateSessionNamedWithTransport_UsesExplicitSessionName(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.CreateNamedWithTransport(context.Background(), "sky", "helper", "my chat", "claude", "/tmp", "claude", "", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{ExplicitName: "sky", Template: "helper", Title: "my chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { - t.Fatalf("CreateNamedWithTransport: %v", err) + t.Fatalf("CreateSessionNamedWithTransport: %v", err) } if info.SessionName != "sky" { t.Fatalf("SessionName = %q, want sky", info.SessionName) @@ -1185,48 +1176,48 @@ func TestCreateNamedWithTransport_UsesExplicitSessionName(t *testing.T) { } } -func TestCreateNamedWithTransport_RejectsReusedName(t *testing.T) { +func TestCreateSessionNamedWithTransport_RejectsReusedName(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - if _, err := mgr.CreateNamedWithTransport(context.Background(), "sky", "helper", "first", "claude", "/tmp", "claude", "", nil, ProviderResume{}, runtime.Config{}); err != nil { - t.Fatalf("first CreateNamedWithTransport: %v", err) + if _, err := mgr.CreateSession(context.Background(), CreateOptions{ExplicitName: "sky", Template: "helper", Title: "first", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}); err != nil { + t.Fatalf("first CreateSessionNamedWithTransport: %v", err) } - if _, err := mgr.CreateNamedWithTransport(context.Background(), "sky", "helper", "second", "claude", "/tmp", "claude", "", nil, ProviderResume{}, runtime.Config{}); err == nil { + if _, err := mgr.CreateSession(context.Background(), CreateOptions{ExplicitName: "sky", Template: "helper", Title: "second", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}); err == nil { t.Fatal("expected session name conflict") } else if !errors.Is(err, ErrSessionNameExists) { t.Fatalf("expected ErrSessionNameExists, got %v", err) } } -func TestCreateNamedWithTransport_ClosedSessionStillReservesName(t *testing.T) { +func TestCreateSessionNamedWithTransport_ClosedSessionStillReservesName(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.CreateNamedWithTransport(context.Background(), "sky", "helper", "first", "claude", "/tmp", "claude", "", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{ExplicitName: "sky", Template: "helper", Title: "first", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { - t.Fatalf("first CreateNamedWithTransport: %v", err) + t.Fatalf("first CreateSessionNamedWithTransport: %v", err) } if err := mgr.Close(info.ID); err != nil { t.Fatalf("Close: %v", err) } - if _, err := mgr.CreateNamedWithTransport(context.Background(), "sky", "helper", "second", "claude", "/tmp", "claude", "", nil, ProviderResume{}, runtime.Config{}); err == nil { + if _, err := mgr.CreateSession(context.Background(), CreateOptions{ExplicitName: "sky", Template: "helper", Title: "second", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}); err == nil { t.Fatal("expected closed session to keep reserving its explicit name") } else if !errors.Is(err, ErrSessionNameExists) { t.Fatalf("expected ErrSessionNameExists, got %v", err) } } -func TestCreateNamedWithTransport_FailedStartDoesNotBurnExplicitName(t *testing.T) { +func TestCreateSessionNamedWithTransport_FailedStartDoesNotBurnExplicitName(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() sp.StartErrors["sky"] = errors.New("boom") - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - if _, err := mgr.CreateNamedWithTransport(context.Background(), "sky", "helper", "first", "claude", "/tmp", "claude", "", nil, ProviderResume{}, runtime.Config{}); err == nil { + if _, err := mgr.CreateSession(context.Background(), CreateOptions{ExplicitName: "sky", Template: "helper", Title: "first", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}); err == nil { t.Fatal("expected start failure") } if err := ensureSessionNameAvailable(store, "sky"); err != nil { @@ -1234,26 +1225,26 @@ func TestCreateNamedWithTransport_FailedStartDoesNotBurnExplicitName(t *testing. } delete(sp.StartErrors, "sky") - info, err := mgr.CreateNamedWithTransport(context.Background(), "sky", "helper", "second", "claude", "/tmp", "claude", "", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{ExplicitName: "sky", Template: "helper", Title: "second", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { - t.Fatalf("retry CreateNamedWithTransport: %v", err) + t.Fatalf("retry CreateSessionNamedWithTransport: %v", err) } if info.SessionName != "sky" { t.Fatalf("SessionName = %q, want sky", info.SessionName) } } -func TestCreateNamedWithTransport_ConvergesLateSuccessStartError(t *testing.T) { +func TestCreateSessionNamedWithTransport_ConvergesLateSuccessStartError(t *testing.T) { store := beads.NewMemStore() sp := &lateSuccessStartProvider{ Fake: runtime.NewFake(), startErr: context.DeadlineExceeded, } - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.CreateNamedWithTransport(context.Background(), "sky", "helper", "first", "claude", "/tmp", "claude", "", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{ExplicitName: "sky", Template: "helper", Title: "first", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { - t.Fatalf("CreateNamedWithTransport: %v", err) + t.Fatalf("CreateSessionNamedWithTransport: %v", err) } if info.SessionName != "sky" { t.Fatalf("SessionName = %q, want sky", info.SessionName) @@ -1270,17 +1261,17 @@ func TestCreateNamedWithTransport_ConvergesLateSuccessStartError(t *testing.T) { } } -func TestCreateNamedWithTransport_ClearsACPRouteAfterDuplicateRuntimeFailure(t *testing.T) { +func TestCreateSessionNamedWithTransport_ClearsACPRouteAfterDuplicateRuntimeFailure(t *testing.T) { store := beads.NewMemStore() defaultSP := runtime.NewFake() acpSP := runtime.NewFake() autoSP := sessionauto.New(defaultSP, acpSP) - mgr := NewManager(store, autoSP) + mgr := NewManagerWithOptions(store, autoSP) if err := acpSP.Start(context.Background(), "sky", runtime.Config{}); err != nil { t.Fatalf("seed acp start: %v", err) } - if _, err := mgr.CreateNamedWithTransport(context.Background(), "sky", "helper", "first", "claude", "/tmp", "claude", "acp", nil, ProviderResume{}, runtime.Config{}); err == nil { + if _, err := mgr.CreateSession(context.Background(), CreateOptions{ExplicitName: "sky", Template: "helper", Title: "first", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "acp", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}); err == nil { t.Fatal("expected duplicate runtime failure") } else if !errors.Is(err, ErrSessionNameExists) { t.Fatalf("expected ErrSessionNameExists, got %v", err) @@ -1289,9 +1280,9 @@ func TestCreateNamedWithTransport_ClearsACPRouteAfterDuplicateRuntimeFailure(t * t.Fatalf("seed acp stop: %v", err) } - info, err := mgr.CreateNamedWithTransport(context.Background(), "sky", "helper", "second", "claude", "/tmp", "claude", "", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{ExplicitName: "sky", Template: "helper", Title: "second", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { - t.Fatalf("retry CreateNamedWithTransport: %v", err) + t.Fatalf("retry CreateSessionNamedWithTransport: %v", err) } if !defaultSP.IsRunning(info.SessionName) { t.Fatalf("default backend should own %q after ACP duplicate cleanup", info.SessionName) @@ -1301,14 +1292,14 @@ func TestCreateNamedWithTransport_ClearsACPRouteAfterDuplicateRuntimeFailure(t * } } -func TestCreateBeadOnlyNamed_UsesExplicitSessionName(t *testing.T) { +func TestCreateSessionBeadOnlyNamed_UsesExplicitSessionName(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.CreateBeadOnlyNamed("sky", "helper", "queued", "claude", "/tmp", "claude", "", nil, ProviderResume{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{BeadOnly: true, ExplicitName: "sky", Template: "helper", Title: "queued", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Resume: ProviderResume{}}) if err != nil { - t.Fatalf("CreateBeadOnlyNamed: %v", err) + t.Fatalf("CreateSessionBeadOnlyNamed: %v", err) } if info.SessionName != "sky" { t.Fatalf("SessionName = %q, want sky", info.SessionName) @@ -1325,14 +1316,14 @@ func TestCreateBeadOnlyNamed_UsesExplicitSessionName(t *testing.T) { } } -func TestCreateAliasedBeadOnlyNamed_SetsPendingCreateMetadata(t *testing.T) { +func TestCreateSessionAliasedBeadOnlyNamed_SetsPendingCreateMetadata(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.CreateAliasedBeadOnlyNamed("worker", "test-city--worker", "worker", "queued", "claude", "/tmp", "claude", "", nil, ProviderResume{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{BeadOnly: true, Alias: "worker", ExplicitName: "test-city--worker", Template: "worker", Title: "queued", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Resume: ProviderResume{}}) if err != nil { - t.Fatalf("CreateAliasedBeadOnlyNamed: %v", err) + t.Fatalf("CreateSessionAliasedBeadOnlyNamed: %v", err) } b, err := store.Get(info.ID) @@ -1351,14 +1342,14 @@ func TestCreateAliasedBeadOnlyNamed_SetsPendingCreateMetadata(t *testing.T) { } } -func TestCreateBeadOnly_SetsPendingCreateClaimForWakeSignal(t *testing.T) { +func TestCreateSessionBeadOnly_SetsPendingCreateClaimForWakeSignal(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.CreateBeadOnly("helper", "queued", "claude", "/tmp", "claude", "", nil, ProviderResume{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{BeadOnly: true, Template: "helper", Title: "queued", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Resume: ProviderResume{}}) if err != nil { - t.Fatalf("CreateBeadOnly: %v", err) + t.Fatalf("CreateSessionBeadOnly: %v", err) } b, err := store.Get(info.ID) if err != nil { @@ -1373,9 +1364,9 @@ func TestCreateRoutesACPSessionsThroughAutoProvider(t *testing.T) { store := beads.NewMemStore() defaultSP := runtime.NewFake() acpSP := runtime.NewFake() - mgr := NewManager(store, sessionauto.New(defaultSP, acpSP)) + mgr := NewManagerWithOptions(store, sessionauto.New(defaultSP, acpSP)) - info, err := mgr.CreateWithTransport(context.Background(), "helper", "acp chat", "claude", "/tmp", "claude", "acp", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "acp chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "acp", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1391,9 +1382,9 @@ func TestCreateRoutesACPSessionsThroughAutoProvider(t *testing.T) { func TestSuspendAndResume(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1446,9 +1437,9 @@ func TestSuspendAndResume(t *testing.T) { func TestClose(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1490,9 +1481,9 @@ func TestCloseRemovesRuntimeMCPSnapshot(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() cityPath := t.TempDir() - mgr := NewManagerWithCityPath(store, sp, cityPath) + mgr := NewManagerWithOptions(store, sp, WithCityPath(cityPath)) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1518,28 +1509,15 @@ func TestCloseRemovesRuntimeMCPSnapshot(t *testing.T) { func TestClose_ConfiguredNamedSessionRetiresIdentifiers(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) - - info, err := mgr.CreateAliasedNamedWithTransportAndMetadata( - context.Background(), - "mayor", - "test-city--mayor", - "mayor", - "Mayor", - "claude", - "/tmp", - "claude", - "", - nil, - ProviderResume{}, - runtime.Config{}, - map[string]string{ + mgr := NewManagerWithOptions(store, sp) + + info, err := mgr.CreateSession( + context.Background(), CreateOptions{Alias: "mayor", ExplicitName: "test-city--mayor", Template: "mayor", Title: "Mayor", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{ "configured_named_session": "true", "configured_named_identity": "mayor", - }, - ) + }}) if err != nil { - t.Fatalf("CreateAliasedNamedWithTransportAndMetadata: %v", err) + t.Fatalf("CreateSessionAliasedNamedWithTransportAndMetadata: %v", err) } if err := mgr.Close(info.ID); err != nil { @@ -1576,29 +1554,14 @@ func TestClose_ConfiguredNamedSessionRetiresIdentifiers(t *testing.T) { func TestClose_NamedSessionByIdentityRetiresIdentifiers(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) - - info, err := mgr.CreateAliasedNamedWithTransportAndMetadata( - context.Background(), - "refinery", - "test-city--refinery", - "refinery", - "Refinery", - "claude", - "/tmp", - "claude", - "", - nil, - ProviderResume{}, - runtime.Config{}, - map[string]string{ - // Identity only — the boolean flag is intentionally absent to - // model the ga-841 stale/legacy bead. + mgr := NewManagerWithOptions(store, sp) + + info, err := mgr.CreateSession( + context.Background(), CreateOptions{Alias: "refinery", ExplicitName: "test-city--refinery", Template: "refinery", Title: "Refinery", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{ "configured_named_identity": "refinery", - }, - ) + }}) if err != nil { - t.Fatalf("CreateAliasedNamedWithTransportAndMetadata: %v", err) + t.Fatalf("CreateSessionAliasedNamedWithTransportAndMetadata: %v", err) } if err := mgr.Close(info.ID); err != nil { @@ -1625,29 +1588,16 @@ func TestClose_NamedSessionByIdentityRetiresIdentifiers(t *testing.T) { func TestCreateInjectsUnifiedSessionRuntimeEnv(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) - - info, err := mgr.CreateAliasedNamedWithTransportAndMetadata( - context.Background(), - "mayor", - "test-city--mayor", - "reviewer", - "Mayor", - "claude", - "/tmp", - "claude", - "", - map[string]string{"GC_AGENT": "stale"}, - ProviderResume{}, - runtime.Config{}, - map[string]string{ + mgr := NewManagerWithOptions(store, sp) + + info, err := mgr.CreateSession( + context.Background(), CreateOptions{Alias: "mayor", ExplicitName: "test-city--mayor", Template: "reviewer", Title: "Mayor", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Env: map[string]string{"GC_AGENT": "stale"}, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{ "configured_named_session": "true", "configured_named_identity": "mayor", "session_origin": "named", - }, - ) + }}) if err != nil { - t.Fatalf("CreateAliasedNamedWithTransportAndMetadata: %v", err) + t.Fatalf("CreateSessionAliasedNamedWithTransportAndMetadata: %v", err) } var start *runtime.Call @@ -1678,29 +1628,16 @@ func TestCreateInjectsUnifiedSessionRuntimeEnv(t *testing.T) { func TestCreateUsesBuiltinAncestorForGCProviderEnv(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) - - info, err := mgr.CreateAliasedNamedWithTransportAndMetadata( - context.Background(), - "mayor", - "test-city--mayor", - "reviewer", - "Mayor", - "claude", - "/tmp", - "claude-max", - "", - nil, - ProviderResume{}, - runtime.Config{}, - map[string]string{ + mgr := NewManagerWithOptions(store, sp) + + info, err := mgr.CreateSession( + context.Background(), CreateOptions{Alias: "mayor", ExplicitName: "test-city--mayor", Template: "reviewer", Title: "Mayor", Command: "claude", WorkDir: "/tmp", Provider: "claude-max", Transport: "", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{ "builtin_ancestor": "claude", "provider_kind": "claude-max", "session_origin": "named", - }, - ) + }}) if err != nil { - t.Fatalf("CreateAliasedNamedWithTransportAndMetadata: %v", err) + t.Fatalf("CreateSessionAliasedNamedWithTransportAndMetadata: %v", err) } cfg := sp.LastStartConfig("test-city--mayor") @@ -1715,7 +1652,7 @@ func TestCreateUsesBuiltinAncestorForGCProviderEnv(t *testing.T) { func TestAttachUsesBuiltinAncestorForGCProviderEnv(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) b, err := store.Create(beads.Bead{ Title: "worker", Type: BeadType, @@ -1750,28 +1687,15 @@ func TestAttachUsesBuiltinAncestorForGCProviderEnv(t *testing.T) { func TestCreateAliaslessMultiSessionUsesConcreteRuntimeIdentity(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) - - info, err := mgr.CreateAliasedNamedWithTransportAndMetadata( - context.Background(), - "", - "ant-adhoc-123", - "demo/ant", - "Ant", - "claude", - "/tmp", - "claude", - "", - nil, - ProviderResume{}, - runtime.Config{}, - map[string]string{ + mgr := NewManagerWithOptions(store, sp) + + info, err := mgr.CreateSession( + context.Background(), CreateOptions{Alias: "", ExplicitName: "ant-adhoc-123", Template: "demo/ant", Title: "Ant", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{ "agent_name": "demo/ant-adhoc-123", "session_origin": "manual", - }, - ) + }}) if err != nil { - t.Fatalf("CreateAliasedNamedWithTransportAndMetadata: %v", err) + t.Fatalf("CreateSessionAliasedNamedWithTransportAndMetadata: %v", err) } var start *runtime.Call @@ -1802,9 +1726,9 @@ func TestCreateAliaslessMultiSessionUsesConcreteRuntimeIdentity(t *testing.T) { func TestCloseSuspended(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1829,9 +1753,9 @@ func TestCloseSuspended(t *testing.T) { func TestClose_IgnoresWaitCancellationFailure(t *testing.T) { store := waitFailStore{MemStore: beads.NewMemStore()} sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1852,14 +1776,14 @@ func TestClose_IgnoresWaitCancellationFailure(t *testing.T) { func TestList(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) // Create two sessions with different templates. - _, err := mgr.Create(context.Background(), "helper", "first", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + _, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "first", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create 1: %v", err) } - info2, err := mgr.Create(context.Background(), "review", "second", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info2, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "review", Title: "second", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create 2: %v", err) } @@ -1911,7 +1835,7 @@ func TestList(t *testing.T) { func TestListNormalizesLegacyDrainedToAsleep(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) bead, err := store.Create(beads.Bead{ Title: "legacy drained", @@ -1950,7 +1874,7 @@ func TestListNormalizesLegacyDrainedToAsleep(t *testing.T) { func TestGetNormalizesAwakeToActive(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) bead, err := store.Create(beads.Bead{ Title: "awake session", @@ -1981,7 +1905,7 @@ func TestGetNormalizesAwakeToActive(t *testing.T) { func TestGetDowngradesStaleActiveStateToAsleep(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) bead, err := store.Create(beads.Bead{ Title: "stale awake session", @@ -2009,9 +1933,9 @@ func TestGetDowngradesStaleActiveStateToAsleep(t *testing.T) { func TestPeek(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2031,9 +1955,9 @@ func TestPeek(t *testing.T) { func TestPeekSuspended(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2050,9 +1974,9 @@ func TestPeekSuspended(t *testing.T) { func TestAttachClosedErrors(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2085,9 +2009,9 @@ func TestSessionNameFor(t *testing.T) { func TestListExcludesClosedFromActiveFilter(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2108,9 +2032,9 @@ func TestListExcludesClosedFromActiveFilter(t *testing.T) { func TestAttachActiveReattach(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2134,9 +2058,9 @@ func TestAttachActiveReattach(t *testing.T) { func TestSuspendCrashedSession(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2161,9 +2085,9 @@ func TestSuspendCrashedSession(t *testing.T) { func TestSuspendCleansDeadRuntimeArtifact(t *testing.T) { store := beads.NewMemStore() sp := &nonRunningStopRecorder{Fake: runtime.NewFake()} - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2180,9 +2104,9 @@ func TestSuspendCleansDeadRuntimeArtifact(t *testing.T) { func TestSuspendKeepsNonRunningCleanupBestEffort(t *testing.T) { store := beads.NewMemStore() sp := &nonRunningStopRecorder{Fake: runtime.NewFake(), stopErr: errors.New("cleanup unavailable")} - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2205,9 +2129,9 @@ func TestSuspendKeepsNonRunningCleanupBestEffort(t *testing.T) { func TestCreateStoresCommand(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude --dangerously-skip-permissions", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude --dangerously-skip-permissions", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2230,7 +2154,7 @@ func TestCreateStoresCommand(t *testing.T) { func TestCreateWithSessionID(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) resume := ProviderResume{ ResumeFlag: "--resume", @@ -2238,7 +2162,7 @@ func TestCreateWithSessionID(t *testing.T) { SessionIDFlag: "--session-id", } - info, err := mgr.Create(context.Background(), "helper", "", "claude --dangerously-skip-permissions", "/tmp", "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude --dangerously-skip-permissions", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2419,7 +2343,7 @@ func TestStripResumeFlagArgRoundTripsBuildResumeCommand(t *testing.T) { func TestCreateWithResumeFlagNoSessionIDFlag(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) // Provider supports resume but NOT Generate & Pass (no SessionIDFlag). resume := ProviderResume{ @@ -2428,7 +2352,7 @@ func TestCreateWithResumeFlagNoSessionIDFlag(t *testing.T) { // SessionIDFlag deliberately empty. } - info, err := mgr.Create(context.Background(), "helper", "", "codex --model o3", "/tmp", "codex", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "codex --model o3", WorkDir: "/tmp", Provider: "codex", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2457,9 +2381,9 @@ func TestCreateWithResumeFlagNoSessionIDFlag(t *testing.T) { func TestCreateFailsCleanup(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFailFake() // all operations fail - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - _, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + _, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err == nil { t.Fatal("Create should fail when provider fails") } @@ -2476,9 +2400,9 @@ func TestCreateFailsCleanup(t *testing.T) { func TestRename(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "old title", "echo test", "/tmp", "test", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "old title", Command: "echo test", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } @@ -2499,22 +2423,10 @@ func TestRename(t *testing.T) { func TestUpdatePresentationSyncsRuntimeAlias(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) - - info, err := mgr.CreateAliasedNamedWithTransport( - context.Background(), - "old-alias", - "", - "helper", - "old title", - "echo test", - "/tmp", - "test", - "", - nil, - ProviderResume{}, - runtime.Config{}, - ) + mgr := NewManagerWithOptions(store, sp) + + info, err := mgr.CreateSession( + context.Background(), CreateOptions{Alias: "old-alias", ExplicitName: "", Template: "helper", Title: "old title", Command: "echo test", WorkDir: "/tmp", Provider: "test", Transport: "", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } @@ -2547,7 +2459,7 @@ func TestUpdatePresentationSyncsRuntimeAlias(t *testing.T) { func TestRenameNonSessionBead(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) // Create a plain bead (not a session). b, err := store.Create(beads.Bead{Title: "not a session", Type: "task"}) @@ -2564,7 +2476,7 @@ func TestRenameNonSessionBead(t *testing.T) { func TestLoadSessionBead_RepairsEmptyType(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) // Create a bead then corrupt its type to empty (simulates crash/migration). b, err := store.Create(beads.Bead{ @@ -2606,7 +2518,7 @@ func TestLoadSessionBead_RepairsEmptyType(t *testing.T) { func TestLoadSessionBead_RepairsEmptyTypeByLabel(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) // Create a bead with gc:session label but NO session_name metadata, // then corrupt its type to empty. The label alone should be enough @@ -2647,7 +2559,7 @@ func TestLoadSessionBead_RepairsEmptyTypeByLabel(t *testing.T) { func TestRenameNotFound(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) if err := mgr.Rename("nonexistent", "title"); err == nil { t.Error("Rename should fail for nonexistent session") @@ -2657,14 +2569,14 @@ func TestRenameNotFound(t *testing.T) { func TestPrune(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) // Create and suspend two sessions. - s1, err := mgr.Create(context.Background(), "default", "S1", "echo s1", "/tmp", "test", nil, ProviderResume{}, runtime.Config{}) + s1, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "default", Title: "S1", Command: "echo s1", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } - s2, err := mgr.Create(context.Background(), "default", "S2", "echo s2", "/tmp", "test", nil, ProviderResume{}, runtime.Config{}) + s2, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "default", Title: "S2", Command: "echo s2", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } @@ -2718,9 +2630,9 @@ func TestPrune(t *testing.T) { func TestPruneDetailedReportsWaitNudges(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "default", "S1", "echo s1", "/tmp", "test", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "default", Title: "S1", Command: "echo s1", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } @@ -2761,12 +2673,12 @@ func (p *falseNegativeRuntimeProvider) IsRunning(name string) bool { func TestObserveRuntime_TreatsLiveProcessAsRunningWhenSessionProbeFalseNegatives(t *testing.T) { base := runtime.NewFake() - mgr := NewManager(beads.NewMemStore(), &falseNegativeRuntimeProvider{ + mgr := NewManagerWithOptions(beads.NewMemStore(), &falseNegativeRuntimeProvider{ Fake: base, falseNames: map[string]bool{"runtime-worker": true}, }) - info, err := mgr.Create(context.Background(), "worker", "runtime-worker", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "worker", Title: "runtime-worker", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2779,9 +2691,9 @@ func TestObserveRuntime_TreatsLiveProcessAsRunningWhenSessionProbeFalseNegatives func TestObserveRuntime_WithoutProcessNamesTreatsRunningSessionAsAlive(t *testing.T) { sp := runtime.NewFake() - mgr := NewManager(beads.NewMemStore(), sp) + mgr := NewManagerWithOptions(beads.NewMemStore(), sp) - info, err := mgr.Create(context.Background(), "worker", "runtime-worker", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "worker", Title: "runtime-worker", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2795,9 +2707,9 @@ func TestObserveRuntime_WithoutProcessNamesTreatsRunningSessionAsAlive(t *testin func TestPruneDetailedContinuesAfterWaitLookupLimit(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "default", "S1", "echo s1", "/tmp", "test", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "default", Title: "S1", Command: "echo s1", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } @@ -2865,14 +2777,14 @@ func TestPruneDetailedContinuesAfterWaitLookupLimit(t *testing.T) { func TestPruneUsesSuspendedAt(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) // Create two sessions and suspend them. - old, err := mgr.Create(context.Background(), "default", "Old", "echo old", "/tmp", "test", nil, ProviderResume{}, runtime.Config{}) + old, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "default", Title: "Old", Command: "echo old", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } - recent, err := mgr.Create(context.Background(), "default", "Recent", "echo recent", "/tmp", "test", nil, ProviderResume{}, runtime.Config{}) + recent, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "default", Title: "Recent", Command: "echo recent", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } @@ -2921,9 +2833,9 @@ func TestPruneUsesSuspendedAt(t *testing.T) { func TestSuspendSetsSuspendedAt(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } @@ -2953,9 +2865,9 @@ func TestSuspendSetsSuspendedAt(t *testing.T) { func TestPruneSkipsActive(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - s1, err := mgr.Create(context.Background(), "default", "Active", "echo a", "/tmp", "test", nil, ProviderResume{}, runtime.Config{}) + s1, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "default", Title: "Active", Command: "echo a", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } @@ -2981,9 +2893,9 @@ func TestPruneSkipsActive(t *testing.T) { func TestPruneDetailedSkipsAsleepByDefault(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "default", "Drained", "echo d", "/tmp", "test", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "default", Title: "Drained", Command: "echo d", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } @@ -3019,10 +2931,10 @@ func TestPruneDetailedSkipsAsleepByDefault(t *testing.T) { func TestPruneDetailedAsleepOptIn(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) // Drained-to-asleep session, 10 days old per slept_at. - drained, err := mgr.Create(context.Background(), "default", "Drained", "echo d", "/tmp", "test", nil, ProviderResume{}, runtime.Config{}) + drained, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "default", Title: "Drained", Command: "echo d", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } @@ -3038,7 +2950,7 @@ func TestPruneDetailedAsleepOptIn(t *testing.T) { } // Suspended session, 10 days old per suspended_at. - suspended, err := mgr.Create(context.Background(), "default", "Suspended", "echo s", "/tmp", "test", nil, ProviderResume{}, runtime.Config{}) + suspended, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "default", Title: "Suspended", Command: "echo s", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } @@ -3050,7 +2962,7 @@ func TestPruneDetailedAsleepOptIn(t *testing.T) { } // Active session (no terminal state) — must always be skipped. - active, err := mgr.Create(context.Background(), "default", "Active", "echo a", "/tmp", "test", nil, ProviderResume{}, runtime.Config{}) + active, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "default", Title: "Active", Command: "echo a", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } @@ -3086,10 +2998,10 @@ func TestPruneDetailedAsleepOptIn(t *testing.T) { func TestPruneDetailedAsleepUsesSleptAt(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) // Asleep session whose slept_at is recent — must NOT be pruned even though CreatedAt is older. - recent, err := mgr.Create(context.Background(), "default", "Recent", "echo r", "/tmp", "test", nil, ProviderResume{}, runtime.Config{}) + recent, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "default", Title: "Recent", Command: "echo r", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } @@ -3101,7 +3013,7 @@ func TestPruneDetailedAsleepUsesSleptAt(t *testing.T) { } // Asleep session whose slept_at is 10d old — must be pruned. - old, err := mgr.Create(context.Background(), "default", "Old", "echo o", "/tmp", "test", nil, ProviderResume{}, runtime.Config{}) + old, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "default", Title: "Old", Command: "echo o", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } @@ -3129,9 +3041,9 @@ func TestPruneDetailedAsleepUsesSleptAt(t *testing.T) { func TestPruneDetailedSkipsAsleepWithoutValidSleptAt(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - missing, err := mgr.Create(context.Background(), "default", "Missing SleptAt", "echo m", "/tmp", "test", nil, ProviderResume{}, runtime.Config{}) + missing, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "default", Title: "Missing SleptAt", Command: "echo m", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } @@ -3139,7 +3051,7 @@ func TestPruneDetailedSkipsAsleepWithoutValidSleptAt(t *testing.T) { t.Fatal(err) } - malformed, err := mgr.Create(context.Background(), "default", "Malformed SleptAt", "echo b", "/tmp", "test", nil, ProviderResume{}, runtime.Config{}) + malformed, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "default", Title: "Malformed SleptAt", Command: "echo b", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } @@ -3162,9 +3074,9 @@ func TestPruneDetailedSkipsAsleepWithoutValidSleptAt(t *testing.T) { func TestPruneDetailedAsleepDrainedMissingSleptAtUsesUpdatedAt(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - drained, err := mgr.Create(context.Background(), "default", "Drained Missing SleptAt", "echo d", "/tmp", "test", nil, ProviderResume{}, runtime.Config{}) + drained, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "default", Title: "Drained Missing SleptAt", Command: "echo d", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } @@ -3202,9 +3114,9 @@ func TestPruneDetailedAsleepDrainedMissingSleptAtUsesUpdatedAt(t *testing.T) { func TestPruneDetailedDrainedOptInIncludesAsleepDrainedMissingSleptAt(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - drained, err := mgr.Create(context.Background(), "default", "Legacy Drained Asleep", "echo d", "/tmp", "test", nil, ProviderResume{}, runtime.Config{}) + drained, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "default", Title: "Legacy Drained Asleep", Command: "echo d", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } @@ -3234,9 +3146,9 @@ func TestPruneDetailedDrainedOptInIncludesAsleepDrainedMissingSleptAt(t *testing func TestPruneDetailedDrainedOptInUsesDrainAt(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - old, err := mgr.Create(context.Background(), "default", "Old Drained", "echo o", "/tmp", "test", nil, ProviderResume{}, runtime.Config{}) + old, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "default", Title: "Old Drained", Command: "echo o", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } @@ -3248,7 +3160,7 @@ func TestPruneDetailedDrainedOptInUsesDrainAt(t *testing.T) { t.Fatal(err) } - missing, err := mgr.Create(context.Background(), "default", "Missing DrainAt", "echo m", "/tmp", "test", nil, ProviderResume{}, runtime.Config{}) + missing, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "default", Title: "Missing DrainAt", Command: "echo m", WorkDir: "/tmp", Provider: "test", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatal(err) } @@ -3272,9 +3184,9 @@ func TestPruneDetailedDrainedOptInUsesDrainAt(t *testing.T) { func TestSendResumesSuspendedSession(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -3311,9 +3223,9 @@ func TestSendResumesSuspendedSession(t *testing.T) { func TestSendImmediateUsesImmediateNudge(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -3343,9 +3255,9 @@ func TestSendImmediateUsesImmediateNudge(t *testing.T) { func TestSendImmediateFallsBackToDefaultNudge(t *testing.T) { store := beads.NewMemStore() fake := runtime.NewFake() - mgr := NewManager(store, &noImmediateProvider{Provider: fake}) + mgr := NewManagerWithOptions(store, &noImmediateProvider{Provider: fake}) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -3371,9 +3283,9 @@ func TestSendImmediateFallsBackToDefaultNudge(t *testing.T) { func TestSendResumesSuspendedSession_SyncsGCDirFromBeadWorkDir(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp/worktree", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp/worktree", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -3410,9 +3322,9 @@ func TestSendResumesSuspendedSession_SyncsGCDirFromBeadWorkDir(t *testing.T) { func TestSendResumesSuspendedSession_PersistsBackfilledInstanceToken(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -3440,9 +3352,9 @@ func TestSendResumesSuspendedACPSessionOnACPBackend(t *testing.T) { store := beads.NewMemStore() defaultSP := runtime.NewFake() acpSP := runtime.NewFake() - mgr := NewManager(store, sessionauto.New(defaultSP, acpSP)) + mgr := NewManagerWithOptions(store, sessionauto.New(defaultSP, acpSP)) - info, err := mgr.CreateWithTransport(context.Background(), "helper", "", "claude", "/tmp", "claude", "acp", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "acp", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -3477,9 +3389,9 @@ func TestSendReRoutesActiveACPSessionBeforeNudge(t *testing.T) { defaultSP := runtime.NewFake() acpSP := runtime.NewFake() autoSP := sessionauto.New(defaultSP, acpSP) - mgr := NewManager(store, autoSP) + mgr := NewManagerWithOptions(store, autoSP) - info, err := mgr.CreateWithTransport(context.Background(), "helper", "", "claude", "/tmp", "claude", "acp", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "acp", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -3537,12 +3449,13 @@ func TestSendBackfillsTransportForLegacyACPSession(t *testing.T) { t.Fatalf("Start ACP session: %v", err) } - mgr := NewManagerWithTransportResolver(store, autoSP, func(template, _ string) string { + mgr := NewManagerWithOptions(store, autoSP, WithTransportResolver(func(template, _ string) string { if template == "helper" { return "acp" } return "" - }) + })) + if err := mgr.Send(context.Background(), legacy.ID, "hello from legacy", "", runtime.Config{}); err != nil { t.Fatalf("Send: %v", err) } @@ -3595,12 +3508,13 @@ func TestGetDoesNotPersistGuessedTransportForLegacySession(t *testing.T) { t.Fatalf("Create legacy bead: %v", err) } - mgr := NewManagerWithTransportResolver(store, autoSP, func(template, _ string) string { + mgr := NewManagerWithOptions(store, autoSP, WithTransportResolver(func(template, _ string) string { if template == "helper" { return "acp" } return "" - }) + })) + if _, err := mgr.Get(legacy.ID); err != nil { t.Fatalf("Get: %v", err) } @@ -3638,12 +3552,12 @@ func TestGetUsesConfiguredTransportForPendingCreateWithoutRuntimeProbe(t *testin t.Fatalf("Create deferred bead: %v", err) } - mgr := NewManagerWithTransportResolver(store, sp, func(template, _ string) string { + mgr := NewManagerWithOptions(store, sp, WithTransportResolver(func(template, _ string) string { if template == "helper" { return "acp" } return "" - }) + })) info, err := mgr.Get(deferred.ID) if err != nil { @@ -3689,12 +3603,12 @@ func TestGetPrefersLiveTransportDetectionOverConfiguredTransportInference(t *tes t.Fatalf("Start default session: %v", err) } - mgr := NewManagerWithTransportResolver(store, autoSP, func(template, _ string) string { + mgr := NewManagerWithOptions(store, autoSP, WithTransportResolver(func(template, _ string) string { if template == "helper" { return "acp" } return "" - }) + })) info, err := mgr.Get(legacy.ID) if err != nil { @@ -3742,12 +3656,12 @@ func TestGetDoesNotInferConfiguredTransportForStoppedLegacySession(t *testing.T) t.Fatalf("SetMetadata(session_name): %v", err) } - mgr := NewManagerWithTransportResolver(store, autoSP, func(template, _ string) string { + mgr := NewManagerWithOptions(store, autoSP, WithTransportResolver(func(template, _ string) string { if template == "helper" { return "acp" } return "" - }) + })) info, err := mgr.Get(legacy.ID) if err != nil { @@ -3795,12 +3709,12 @@ func TestGetDoesNotInferConfiguredTransportForStoppedLegacySessionWithPolicyFall t.Fatalf("SetMetadata(session_name): %v", err) } - mgr := NewManagerWithTransportPolicyResolverAndCityPath(store, autoSP, "", func(template, _ string) (string, bool) { + mgr := NewManagerWithOptions(store, autoSP, WithCityPath(""), WithTransportPolicyResolver(func(template, _ string) (string, bool) { if template == "helper" { return "acp", true } return "", false - }) + })) info, err := mgr.Get(legacy.ID) if err != nil { @@ -3845,7 +3759,7 @@ func TestGetInfersACPTransportFromStoredMCPMetadata(t *testing.T) { t.Fatalf("Create legacy bead: %v", err) } - mgr := NewManagerWithTransportResolver(store, autoSP, nil) + mgr := NewManagerWithOptions(store, autoSP, WithTransportResolver(nil)) info, err := mgr.Get(legacy.ID) if err != nil { t.Fatalf("Get: %v", err) @@ -3858,9 +3772,9 @@ func TestGetInfersACPTransportFromStoredMCPMetadata(t *testing.T) { func TestSendConvergesWhenSessionAlreadyResumed(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -3897,9 +3811,9 @@ func TestSendConvergesWhenSessionAlreadyResumed(t *testing.T) { func TestSendRequiresResumeCommandForSuspendedSession(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -3916,9 +3830,9 @@ func TestSendRequiresResumeCommandForSuspendedSession(t *testing.T) { func TestSendClosedSessionReturnsErrSessionClosed(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -3936,9 +3850,9 @@ func TestSendDoesNotSuppressNonDuplicateResumeError(t *testing.T) { base := runtime.NewFake() sp := &startOverrideProvider{Fake: base} store := beads.NewMemStore() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -3959,9 +3873,9 @@ func TestSendDoesNotSuppressNonDuplicateResumeError(t *testing.T) { func TestStopTurnInterruptsActiveSession(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -3984,9 +3898,9 @@ func TestStopTurnInterruptsActiveSession(t *testing.T) { func TestStopTurnAllowsPoolManagedSession(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "pool-worker", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "pool-worker", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -4016,9 +3930,9 @@ func TestStopTurnAllowsPoolManagedSession(t *testing.T) { func TestStopTurnAllowsPoolSlotOnlySession(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "pool-slot-worker", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "pool-slot-worker", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -4048,9 +3962,9 @@ func TestStopTurnAllowsPoolSlotOnlySession(t *testing.T) { func TestPendingAndRespond(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -4090,9 +4004,9 @@ func TestPendingAndRespond(t *testing.T) { func TestPendingByNameProbesProviderWithoutBeadLookup(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -4163,9 +4077,9 @@ func (p *respondSessionGoneProvider) Respond(_ string, _ runtime.InteractionResp func TestPendingAndRespondTreatMissingRuntimeSessionAsNoPending(t *testing.T) { store := beads.NewMemStore() sp := &pendingSessionGoneProvider{Fake: runtime.NewFake()} - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -4190,9 +4104,9 @@ func TestPendingAndRespondTreatMissingRuntimeSessionAsNoPending(t *testing.T) { func TestRespondTreatsRuntimeSessionGoneDuringResponseAsNoPending(t *testing.T) { store := beads.NewMemStore() sp := &respondSessionGoneProvider{Fake: runtime.NewFake()} - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -4209,9 +4123,9 @@ func TestPendingAndRespondDoNotSwallowUnrelatedNotFoundErrors(t *testing.T) { Fake: runtime.NewFake(), err: fmt.Errorf("loading config file: not found"), } - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -4245,9 +4159,9 @@ func TestPendingAndRespondDoNotSwallowUnrelatedNotFoundErrors(t *testing.T) { func TestSendRejectsPendingInteraction(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -4272,9 +4186,9 @@ func TestSendRejectsPendingInteraction(t *testing.T) { func TestSendImmediateRejectsPendingInteraction(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -4299,7 +4213,7 @@ func TestSendImmediateRejectsPendingInteraction(t *testing.T) { func TestTranscriptPathPrefersSessionKey(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) workDir := t.TempDir() resume := ProviderResume{ @@ -4307,7 +4221,7 @@ func TestTranscriptPathPrefersSessionKey(t *testing.T) { ResumeStyle: "flag", SessionIDFlag: "--session-id", } - info, err := mgr.Create(context.Background(), "helper", "", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -4338,7 +4252,7 @@ func TestTranscriptPathPrefersSessionKey(t *testing.T) { func TestTranscriptPathAllowsClosedSession(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) workDir := t.TempDir() resume := ProviderResume{ @@ -4346,7 +4260,7 @@ func TestTranscriptPathAllowsClosedSession(t *testing.T) { ResumeStyle: "flag", SessionIDFlag: "--session-id", } - info, err := mgr.Create(context.Background(), "helper", "", "claude", workDir, "claude", nil, resume, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: resume, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -4376,13 +4290,13 @@ func TestTranscriptPathAllowsClosedSession(t *testing.T) { func TestTranscriptPathSkipsAmbiguousWorkDirFallback(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) workDir := t.TempDir() - if _, err := mgr.Create(context.Background(), "helper", "one", "claude", workDir, "claude", nil, ProviderResume{}, runtime.Config{}); err != nil { + if _, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "one", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}); err != nil { t.Fatalf("Create one: %v", err) } - info2, err := mgr.Create(context.Background(), "helper", "two", "claude", workDir, "claude", nil, ProviderResume{}, runtime.Config{}) + info2, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "two", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create two: %v", err) } @@ -4409,14 +4323,14 @@ func TestTranscriptPathSkipsAmbiguousWorkDirFallback(t *testing.T) { func TestTranscriptPathClosedSessionSkipsAmbiguousHistoricalWorkDirFallback(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) workDir := t.TempDir() - info1, err := mgr.Create(context.Background(), "helper", "one", "codex", workDir, "codex", nil, ProviderResume{}, runtime.Config{}) + info1, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "one", Command: "codex", WorkDir: workDir, Provider: "codex", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create one: %v", err) } - info2, err := mgr.Create(context.Background(), "helper", "two", "codex", workDir, "codex", nil, ProviderResume{}, runtime.Config{}) + info2, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "two", Command: "codex", WorkDir: workDir, Provider: "codex", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create two: %v", err) } @@ -4450,13 +4364,13 @@ func TestTranscriptPathClosedSessionSkipsAmbiguousHistoricalWorkDirFallback(t *t func TestTranscriptPathSameWorkDirDifferentProvidersUsesProviderSpecificFallback(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) workDir := t.TempDir() - if _, err := mgr.Create(context.Background(), "helper", "claude", "claude", workDir, "claude", nil, ProviderResume{}, runtime.Config{}); err != nil { + if _, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "claude", Command: "claude", WorkDir: workDir, Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}); err != nil { t.Fatalf("Create claude: %v", err) } - info, err := mgr.Create(context.Background(), "helper", "codex", "codex", workDir, "codex", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "codex", Command: "codex", WorkDir: workDir, Provider: "codex", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create codex: %v", err) } @@ -4484,9 +4398,9 @@ func TestTranscriptPathSameWorkDirDifferentProvidersUsesProviderSpecificFallback func TestKill_ActiveState(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.CreateNamedWithTransport(context.Background(), "sky", "helper", "test", "claude", "/tmp", "claude", "", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{ExplicitName: "sky", Template: "helper", Title: "test", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -4498,9 +4412,9 @@ func TestKill_ActiveState(t *testing.T) { func TestKill_AwakeState(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.CreateNamedWithTransport(context.Background(), "sky", "helper", "test", "claude", "/tmp", "claude", "", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{ExplicitName: "sky", Template: "helper", Title: "test", Command: "claude", WorkDir: "/tmp", Provider: "claude", Transport: "", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -4515,7 +4429,7 @@ func TestKill_AwakeState(t *testing.T) { func TestKill_StoppedState_NotRunning(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) b, err := store.Create(beads.Bead{ Title: "helper", @@ -4535,7 +4449,7 @@ func TestKill_UnknownState_ButRunning(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() _ = sp.Start(context.Background(), "sky", runtime.Config{Command: "claude"}) - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) b, err := store.Create(beads.Bead{ Title: "helper", @@ -4559,12 +4473,12 @@ func TestEnsureRunning_RetriesWithoutStaleSessionKey(t *testing.T) { base := runtime.NewFake() sp := &failOnceStartProvider{Fake: base} - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "worker", "", "claude --dangerously", "/tmp", "claude", nil, ProviderResume{ + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "worker", Title: "", Command: "claude --dangerously", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{ ResumeFlag: "--resume", SessionIDFlag: "--session-id", - }, runtime.Config{}) + }, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -4607,12 +4521,12 @@ func TestEnsureRunning_StaleKeyRetryAlsoFails(t *testing.T) { base := runtime.NewFake() sp := &dieAndFailProvider{Fake: base} - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "worker", "", "claude --dangerously", "/tmp", "claude", nil, ProviderResume{ + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "worker", Title: "", Command: "claude --dangerously", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{ ResumeFlag: "--resume", SessionIDFlag: "--session-id", - }, runtime.Config{}) + }, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -4647,12 +4561,12 @@ func TestEnsureRunning_RetriesAfterStartupDeathError(t *testing.T) { base := runtime.NewFake() sp := &startupDeathProvider{Fake: base} - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "worker", "", "claude --dangerously", "/tmp", "claude", nil, ProviderResume{ + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "worker", Title: "", Command: "claude --dangerously", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{ ResumeFlag: "--resume", SessionIDFlag: "--session-id", - }, runtime.Config{}) + }, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -4709,12 +4623,12 @@ func TestEnsureRunning_StartupDeathWithoutStrippableResumeRecovers(t *testing.T) base := runtime.NewFake() sp := &startupDeathProvider{Fake: base} - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "worker", "", "claude --dangerously", "/tmp", "claude", nil, ProviderResume{ + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "worker", Title: "", Command: "claude --dangerously", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{ ResumeFlag: "--resume", SessionIDFlag: "--session-id", - }, runtime.Config{}) + }, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -4771,12 +4685,12 @@ func TestEnsureRunning_RetriesWhenResumeKeyDiverged(t *testing.T) { base := runtime.NewFake() sp := &startupDeathProvider{Fake: base} - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "worker", "", "claude --dangerously", "/tmp", "claude", nil, ProviderResume{ + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "worker", Title: "", Command: "claude --dangerously", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{ ResumeFlag: "--resume", SessionIDFlag: "--session-id", - }, runtime.Config{}) + }, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -4818,13 +4732,13 @@ func TestEnsureRunning_RetriesWhenResumeKeyDivergedKeepsEarlierResumeText(t *tes base := runtime.NewFake() sp := &startupDeathProvider{Fake: base} - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "worker", "", `claude --label "--resume keep-me"`, "/tmp", "claude", nil, ProviderResume{ + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "worker", Title: "", Command: `claude --label "--resume keep-me"`, WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{ ResumeFlag: "--resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", - }, runtime.Config{}) + }, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -4864,13 +4778,13 @@ func TestEnsureRunning_RetriesExplicitResumeCommandWhenResumeKeyDiverged(t *test base := runtime.NewFake() sp := &startupDeathProvider{Fake: base} - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "worker", "", "claude --dangerously-skip-permissions", "/tmp", "claude", nil, ProviderResume{ + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "worker", Title: "", Command: "claude --dangerously-skip-permissions", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{ ResumeFlag: "--resume", SessionIDFlag: "--session-id", ResumeCommand: "claude --resume {{.SessionKey}} --dangerously-skip-permissions", - }, runtime.Config{}) + }, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -4918,13 +4832,13 @@ func TestEnsureRunning_RetriesWhenResumeFlagIsEmpty(t *testing.T) { base := runtime.NewFake() sp := &startupDeathProvider{Fake: base} - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) // Create a session without resume capability — ProviderResume{} // yields an empty resume_flag in bead metadata. The same shape // arises for any configured-named-always session whose start // command lacks a --resume-style flag. - info, err := mgr.Create(context.Background(), "worker", "", "fakecmd --follow worker", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "worker", Title: "", Command: "fakecmd --follow worker", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -4981,12 +4895,12 @@ func TestEnsureRunning_StartupDeathClearMetadataFailurePropagates(t *testing.T) store := failMetadataKeyStore{MemStore: beads.NewMemStore(), key: "session_key"} base := runtime.NewFake() sp := &startupDeathProvider{Fake: base} - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "worker", "", "claude --dangerously", "/tmp", "claude", nil, ProviderResume{ + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "worker", Title: "", Command: "claude --dangerously", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{ ResumeFlag: "--resume", SessionIDFlag: "--session-id", - }, runtime.Config{}) + }, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -5031,9 +4945,9 @@ func TestEnsureRunning_StartupDeathClearMetadataFailurePropagates(t *testing.T) func TestCloseDetailed_StopErrorLeavesBeadOpen(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -5059,9 +4973,9 @@ func TestCloseDetailed_StopErrorLeavesBeadOpen(t *testing.T) { func TestCloseDetailed_StopSuccessClosesBead(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -5082,9 +4996,9 @@ func TestCloseDetailed_StopSuccessClosesBead(t *testing.T) { func TestPersistInvocationUsageCursor(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "chat", "claude", "/tmp", "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "chat", Command: "claude", WorkDir: "/tmp", Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } diff --git a/internal/session/submit_test.go b/internal/session/submit_test.go index 564a8fdeb2..e86bfab325 100644 --- a/internal/session/submit_test.go +++ b/internal/session/submit_test.go @@ -136,9 +136,9 @@ func TestInterruptStrategyUsesPiProviderFamilyAlias(t *testing.T) { func TestSubmitDefaultResumesSuspendedClaudeSessionAndWaitsForIdleNudge(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", t.TempDir(), "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: t.TempDir(), Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -171,9 +171,9 @@ func TestSubmitDefaultResumesSuspendedClaudeSessionAndWaitsForIdleNudge(t *testi func TestSubmitDefaultResumesSuspendedCodexSessionAndNudgesImmediately(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "codex", t.TempDir(), "codex", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "codex", WorkDir: t.TempDir(), Provider: "codex", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -203,9 +203,9 @@ func TestSubmitDefaultResumesSuspendedCodexSessionAndNudgesImmediately(t *testin func TestSubmitDefaultCodexDismissesDeferredDialogsOnFirstDelivery(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "codex", t.TempDir(), "codex", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "codex", WorkDir: t.TempDir(), Provider: "codex", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -239,9 +239,9 @@ func TestSubmitDefaultCodexDismissesDeferredDialogsOnFirstDelivery(t *testing.T) func TestSubmitDefaultCodexSkipsDeferredDialogsAfterVerification(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "codex", t.TempDir(), "codex", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "codex", WorkDir: t.TempDir(), Provider: "codex", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -267,9 +267,9 @@ func TestSubmitDefaultCodexSkipsDeferredDialogsAfterVerification(t *testing.T) { func TestSubmitDefaultResumesSuspendedGeminiSessionAndNudgesImmediately(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "gemini", t.TempDir(), "gemini", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "gemini", WorkDir: t.TempDir(), Provider: "gemini", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -305,9 +305,9 @@ func TestSubmitDefaultResumesSuspendedGeminiSessionAndNudgesImmediately(t *testi func TestSubmitDefaultToRunningGeminiSessionWaitsForIdleNudge(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "gemini", t.TempDir(), "gemini", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "gemini", WorkDir: t.TempDir(), Provider: "gemini", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -340,7 +340,7 @@ func TestSubmitDefaultToRunningGeminiSessionWaitsForIdleNudge(t *testing.T) { func TestSubmitDefaultConfirmsLiveCreatingSession(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) workDir := t.TempDir() sessionName := "s-live-create" @@ -388,9 +388,9 @@ func TestSubmitFollowUpQueuesDeferredMessageAndStartsCodexPoller(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() cityPath := t.TempDir() - mgr := NewManagerWithCityPath(store, sp, cityPath) + mgr := NewManagerWithOptions(store, sp, WithCityPath(cityPath)) - info, err := mgr.Create(context.Background(), "helper", "", "codex", t.TempDir(), "codex", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "codex", WorkDir: t.TempDir(), Provider: "codex", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -761,9 +761,9 @@ func TestSubmitFollowUpQueuesDeferredMessageForPoolManagedSession(t *testing.T) store := beads.NewMemStore() sp := runtime.NewFake() cityPath := t.TempDir() - mgr := NewManagerWithCityPath(store, sp, cityPath) + mgr := NewManagerWithOptions(store, sp, WithCityPath(cityPath)) - info, err := mgr.Create(context.Background(), "helper", "", "codex", t.TempDir(), "codex", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "codex", WorkDir: t.TempDir(), Provider: "codex", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -796,9 +796,9 @@ func TestSubmitFollowUpOnSuspendedSessionFallsBackToImmediateSend(t *testing.T) store := beads.NewMemStore() sp := runtime.NewFake() cityPath := t.TempDir() - mgr := NewManagerWithCityPath(store, sp, cityPath) + mgr := NewManagerWithOptions(store, sp, WithCityPath(cityPath)) - info, err := mgr.Create(context.Background(), "helper", "", "claude", t.TempDir(), "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: t.TempDir(), Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -839,9 +839,9 @@ func TestSubmitFollowUpOnAsleepSessionFallsBackToImmediateSend(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() cityPath := t.TempDir() - mgr := NewManagerWithCityPath(store, sp, cityPath) + mgr := NewManagerWithOptions(store, sp, WithCityPath(cityPath)) - info, err := mgr.Create(context.Background(), "helper", "", "claude", t.TempDir(), "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: t.TempDir(), Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -885,9 +885,9 @@ func TestSubmitDefaultQueuesWhenWakeAlreadyRequested(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() cityPath := t.TempDir() - mgr := NewManagerWithCityPath(store, sp, cityPath) + mgr := NewManagerWithOptions(store, sp, WithCityPath(cityPath)) - info, err := mgr.Create(context.Background(), "helper", "", "claude", t.TempDir(), "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: t.TempDir(), Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -994,9 +994,9 @@ func TestSubmissionCapabilitiesDisableInterruptNowForWrappedAntigravity(t *testi func TestSubmitInterruptNowRejectsAntigravitySession(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "antigravity", t.TempDir(), "antigravity", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "antigravity", WorkDir: t.TempDir(), Provider: "antigravity", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1019,9 +1019,9 @@ func TestSubmitInterruptNowRejectsAntigravitySession(t *testing.T) { func TestSubmitInterruptNowUsesInterruptAndIdleWaitForGemini(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "gemini", t.TempDir(), "gemini", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "gemini", WorkDir: t.TempDir(), Provider: "gemini", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1088,9 +1088,9 @@ func TestSubmitInterruptNowUsesInterruptAndIdleWaitForGemini(t *testing.T) { func TestSubmitInterruptNowAllowsPoolManagedCodexSession(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "codex", t.TempDir(), "codex", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "codex", WorkDir: t.TempDir(), Provider: "codex", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1149,9 +1149,9 @@ func TestSubmitInterruptNowAllowsPoolManagedCodexSession(t *testing.T) { func TestSubmitInterruptNowUsesInterruptAndIdleWaitForClaude(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", t.TempDir(), "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: t.TempDir(), Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1201,9 +1201,9 @@ func TestSubmitInterruptNowFallsBackToRestartOnIdleTimeout(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() sp.WaitForIdleErrors = map[string]error{} - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "claude", t.TempDir(), "claude", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "claude", WorkDir: t.TempDir(), Provider: "claude", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1237,9 +1237,9 @@ func TestSubmitInterruptNowFallsBackToRestartOnIdleTimeout(t *testing.T) { func TestSubmitInterruptNowUsesControlCFallbackAfterSoftEscapeTimeoutForCodex(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "codex", t.TempDir(), "codex", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "codex", WorkDir: t.TempDir(), Provider: "codex", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1286,9 +1286,9 @@ func TestSubmitInterruptNowUsesControlCFallbackAfterSoftEscapeTimeoutForCodex(t func TestSubmitInterruptNowFallsBackToRestartOnInterruptBoundaryTimeoutForCodex(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "codex", t.TempDir(), "codex", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "codex", WorkDir: t.TempDir(), Provider: "codex", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1322,9 +1322,9 @@ func TestSubmitInterruptNowFallsBackToRestartOnInterruptBoundaryTimeoutForCodex( func TestSubmitInterruptNowHardRestartsAndTruncatesPiPendingTurn(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "pi --session abc123", t.TempDir(), "pi", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "pi --session abc123", WorkDir: t.TempDir(), Provider: "pi", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1426,9 +1426,9 @@ func TestSubmitInterruptNowHardRestartsAndTruncatesPiPendingTurn(t *testing.T) { func TestSubmitInterruptNowRestoresPiSessionWhenTranscriptResetFails(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "pi --session abc123", t.TempDir(), "pi", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "pi --session abc123", WorkDir: t.TempDir(), Provider: "pi", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1500,9 +1500,9 @@ func TestSubmitInterruptNowRestoresPiSessionWhenTranscriptResetFails(t *testing. func TestSubmitInterruptNowTruncatesPiTranscriptBySessionKey(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "pi --session target", t.TempDir(), "pi", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "pi --session target", WorkDir: t.TempDir(), Provider: "pi", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1571,9 +1571,9 @@ func TestSubmitInterruptNowTruncatesPiTranscriptBySessionKey(t *testing.T) { func TestSubmitInterruptNowFailsClosedOnPiSessionKeyMismatch(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "pi --session target", t.TempDir(), "pi", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "pi --session target", WorkDir: t.TempDir(), Provider: "pi", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1618,9 +1618,9 @@ func TestSubmitInterruptNowFailsClosedOnPiSessionKeyMismatch(t *testing.T) { func TestSubmitInterruptNowFailsClosedOnAmbiguousPiTranscript(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "pi", t.TempDir(), "pi", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "pi", WorkDir: t.TempDir(), Provider: "pi", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1675,9 +1675,9 @@ func TestSubmitInterruptNowFailsClosedOnAmbiguousPiTranscript(t *testing.T) { func TestSubmitInterruptNowPiContinuesWhenSessionFileMissing(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "pi --session missing", t.TempDir(), "pi", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "pi --session missing", WorkDir: t.TempDir(), Provider: "pi", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1716,13 +1716,13 @@ func TestSubmitInterruptNowPiContinuesWhenSessionFileMissing(t *testing.T) { func TestSubmitInterruptNowFindsPiDefaultSessionPath(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) home := t.TempDir() t.Setenv("HOME", home) t.Setenv("GC_HOME", filepath.Join(home, ".gc")) - info, err := mgr.Create(context.Background(), "helper", "", "pi --session abc123", t.TempDir(), "pi", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "pi --session abc123", WorkDir: t.TempDir(), Provider: "pi", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1753,9 +1753,9 @@ func TestSubmitInterruptNowFindsPiDefaultSessionPath(t *testing.T) { func TestStopTurnUsesSoftEscapeAndIdleWaitForCodex(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "codex", t.TempDir(), "codex", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "codex", WorkDir: t.TempDir(), Provider: "codex", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -1790,9 +1790,9 @@ func TestStopTurnUsesSoftEscapeAndIdleWaitForCodex(t *testing.T) { func TestStopTurnUsesControlCFallbackAfterSoftEscapeTimeoutForCodex(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := NewManager(store, sp) + mgr := NewManagerWithOptions(store, sp) - info, err := mgr.Create(context.Background(), "helper", "", "codex", t.TempDir(), "codex", nil, ProviderResume{}, runtime.Config{}) + info, err := mgr.CreateSession(context.Background(), CreateOptions{Template: "helper", Title: "", Command: "codex", WorkDir: t.TempDir(), Provider: "codex", Env: nil, Resume: ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } diff --git a/internal/worker/factory.go b/internal/worker/factory.go index ebc911a2e1..e7ca36bb3d 100644 --- a/internal/worker/factory.go +++ b/internal/worker/factory.go @@ -52,16 +52,16 @@ func NewFactory(cfg FactoryConfig) (*Factory, error) { var manager *sessionpkg.Manager switch { case cfg.ResolveTransport != nil: - manager = sessionpkg.NewManagerWithTransportResolverAndCityPath( + manager = sessionpkg.NewManagerWithOptions( cfg.Store, cfg.Provider, - cfg.CityPath, - cfg.ResolveTransport, + sessionpkg.WithCityPath(cfg.CityPath), + sessionpkg.WithTransportResolver(cfg.ResolveTransport), ) case cfg.CityPath != "": - manager = sessionpkg.NewManagerWithCityPath(cfg.Store, cfg.Provider, cfg.CityPath) + manager = sessionpkg.NewManagerWithOptions(cfg.Store, cfg.Provider, sessionpkg.WithCityPath(cfg.CityPath)) default: - manager = sessionpkg.NewManager(cfg.Store, cfg.Provider) + manager = sessionpkg.NewManagerWithOptions(cfg.Store, cfg.Provider) } return newFactory(manager, cfg.Store, cfg.Provider, cfg.SearchPaths, cfg.Recorder, cfg.UsageSink, cfg.ResolveSessionRuntime, cfg.Pricing) } diff --git a/internal/worker/factory_test.go b/internal/worker/factory_test.go index 1e00fc1300..1e833d2859 100644 --- a/internal/worker/factory_test.go +++ b/internal/worker/factory_test.go @@ -121,18 +121,9 @@ func TestFactoryTranscriptMethodsUseConfiguredSearchPaths(t *testing.T) { func TestFactorySessionByIDResolvesSessionRuntime(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - manager := sessionpkg.NewManager(store, sp) - - info, err := manager.CreateBeadOnly( - "worker", - "Probe", - "", - t.TempDir(), - "legacy-provider", - "", - nil, - sessionpkg.ProviderResume{SessionIDFlag: "--stale-session-id"}, - ) + manager := sessionpkg.NewManagerWithOptions(store, sp) + + info, err := manager.CreateSession(context.Background(), sessionpkg.CreateOptions{BeadOnly: true, Template: "worker", Title: "Probe", Command: "", WorkDir: t.TempDir(), Provider: "legacy-provider", Transport: "", Resume: sessionpkg.ProviderResume{SessionIDFlag: "--stale-session-id"}}) if err != nil { t.Fatalf("CreateBeadOnly: %v", err) } @@ -208,18 +199,9 @@ func TestFactorySessionByIDResolvesSessionRuntime(t *testing.T) { func TestFactoryTransportResolverReceivesProviderForLegacyProviderSession(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - manager := sessionpkg.NewManager(store, sp) - - info, err := manager.CreateBeadOnly( - "opencode", - "Probe", - "", - t.TempDir(), - "opencode", - "", - nil, - sessionpkg.ProviderResume{}, - ) + manager := sessionpkg.NewManagerWithOptions(store, sp) + + info, err := manager.CreateSession(context.Background(), sessionpkg.CreateOptions{BeadOnly: true, Template: "opencode", Title: "Probe", Command: "", WorkDir: t.TempDir(), Provider: "opencode", Transport: "", Resume: sessionpkg.ProviderResume{}}) if err != nil { t.Fatalf("CreateBeadOnly: %v", err) } @@ -266,18 +248,9 @@ func TestFactoryTransportResolverReceivesProviderForLegacyProviderSession(t *tes func TestFactorySessionByIDPropagatesResolvedRuntimeError(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - manager := sessionpkg.NewManager(store, sp) - - info, err := manager.CreateBeadOnly( - "worker", - "Probe", - "", - t.TempDir(), - "legacy-provider", - "", - nil, - sessionpkg.ProviderResume{SessionIDFlag: "--stale-session-id"}, - ) + manager := sessionpkg.NewManagerWithOptions(store, sp) + + info, err := manager.CreateSession(context.Background(), sessionpkg.CreateOptions{BeadOnly: true, Template: "worker", Title: "Probe", Command: "", WorkDir: t.TempDir(), Provider: "legacy-provider", Transport: "", Resume: sessionpkg.ProviderResume{SessionIDFlag: "--stale-session-id"}}) if err != nil { t.Fatalf("CreateBeadOnly: %v", err) } @@ -303,19 +276,10 @@ func TestFactorySessionByIDPropagatesResolvedRuntimeError(t *testing.T) { func TestFactorySessionByIDPreservesTemplateInWorkerOperationEvents(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - manager := sessionpkg.NewManager(store, sp) + manager := sessionpkg.NewManagerWithOptions(store, sp) recorder := events.NewFake() - info, err := manager.CreateBeadOnly( - "myrig/worker", - "Probe", - "", - t.TempDir(), - "stub", - "", - nil, - sessionpkg.ProviderResume{SessionIDFlag: "--session-id"}, - ) + info, err := manager.CreateSession(context.Background(), sessionpkg.CreateOptions{BeadOnly: true, Template: "myrig/worker", Title: "Probe", Command: "", WorkDir: t.TempDir(), Provider: "stub", Transport: "", Resume: sessionpkg.ProviderResume{SessionIDFlag: "--session-id"}}) if err != nil { t.Fatalf("CreateBeadOnly: %v", err) } @@ -353,19 +317,10 @@ func TestFactorySessionByIDPreservesTemplateInWorkerOperationEvents(t *testing.T func TestFactoryHandleForTargetResolvesRuntimeSessionMeta(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - manager := sessionpkg.NewManager(store, sp) - - info, err := manager.Create( - context.Background(), - "worker", - "Probe", - "", - t.TempDir(), - "stub", - nil, - sessionpkg.ProviderResume{}, - runtime.Config{}, - ) + manager := sessionpkg.NewManagerWithOptions(store, sp) + + info, err := manager.CreateSession( + context.Background(), sessionpkg.CreateOptions{Template: "worker", Title: "Probe", Command: "", WorkDir: t.TempDir(), Provider: "stub", Env: nil, Resume: sessionpkg.ProviderResume{}, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } diff --git a/internal/worker/handle_lifecycle.go b/internal/worker/handle_lifecycle.go index 70a397ac95..a1a32bae9f 100644 --- a/internal/worker/handle_lifecycle.go +++ b/internal/worker/handle_lifecycle.go @@ -382,18 +382,19 @@ func (h *SessionHandle) ensureSessionID() (string, error) { } func (h *SessionHandle) createDeferredLocked() (sessionpkg.Info, error) { - info, err := h.manager.CreateAliasedBeadOnlyNamedWithMetadata( - h.session.Alias, - h.session.ExplicitName, - h.session.Template, - h.session.Title, - h.session.Command, - h.session.WorkDir, - h.session.Provider, - h.session.Transport, - h.session.Resume, - cloneStringMap(h.session.Metadata), - ) + info, err := h.manager.CreateSession(context.Background(), sessionpkg.CreateOptions{ + BeadOnly: true, + Alias: h.session.Alias, + ExplicitName: h.session.ExplicitName, + Template: h.session.Template, + Title: h.session.Title, + Command: h.session.Command, + WorkDir: h.session.WorkDir, + Provider: h.session.Provider, + Transport: h.session.Transport, + Resume: h.session.Resume, + ExtraMeta: cloneStringMap(h.session.Metadata), + }) if err != nil { return sessionpkg.Info{}, err } @@ -402,21 +403,20 @@ func (h *SessionHandle) createDeferredLocked() (sessionpkg.Info, error) { } func (h *SessionHandle) createStartedLocked(ctx context.Context) (sessionpkg.Info, error) { - info, err := h.manager.CreateAliasedNamedWithTransportAndMetadata( - ctx, - h.session.Alias, - h.session.ExplicitName, - h.session.Template, - h.session.Title, - h.session.Command, - h.session.WorkDir, - h.session.Provider, - h.session.Transport, - cloneStringMap(h.session.Env), - h.session.Resume, - cloneRuntimeConfig(h.session.Hints), - cloneStringMap(h.session.Metadata), - ) + info, err := h.manager.CreateSession(ctx, sessionpkg.CreateOptions{ + Alias: h.session.Alias, + ExplicitName: h.session.ExplicitName, + Template: h.session.Template, + Title: h.session.Title, + Command: h.session.Command, + WorkDir: h.session.WorkDir, + Provider: h.session.Provider, + Transport: h.session.Transport, + Env: cloneStringMap(h.session.Env), + Resume: h.session.Resume, + Hints: cloneRuntimeConfig(h.session.Hints), + ExtraMeta: cloneStringMap(h.session.Metadata), + }) if err != nil { return sessionpkg.Info{}, err } diff --git a/internal/worker/handle_test.go b/internal/worker/handle_test.go index aa80907828..e41324c166 100644 --- a/internal/worker/handle_test.go +++ b/internal/worker/handle_test.go @@ -108,7 +108,7 @@ func TestSessionHandleStateBusyDoesNotPrimeHistoryCache(t *testing.T) { workDir := t.TempDir() store := beads.NewMemStore() sp := runtime.NewFake() - manager := sessionpkg.NewManager(store, sp) + manager := sessionpkg.NewManagerWithOptions(store, sp) handle, err := NewSessionHandle(SessionHandleConfig{ Manager: manager, SearchPaths: []string{searchBase}, @@ -1699,7 +1699,7 @@ func TestRuntimeHandleNudgeWaitIdleUnsupportedProviderReturnsUndelivered(t *test func TestSessionCatalogUsesWorkerBoundary(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - mgr := sessionpkg.NewManagerWithCityPath(store, sp, t.TempDir()) + mgr := sessionpkg.NewManagerWithOptions(store, sp, sessionpkg.WithCityPath(t.TempDir())) handle, err := NewSessionHandle(SessionHandleConfig{ Manager: mgr, Session: SessionSpec{ @@ -1965,23 +1965,14 @@ func TestSessionHandleStartUsesSessionIDOnFirstStartAndResumeAfterSuspend(t *tes func TestSessionHandleStartUsesCurrentResumeOverridesAfterSuspend(t *testing.T) { store := beads.NewMemStore() sp := runtime.NewFake() - manager := sessionpkg.NewManager(store, sp) - - info, err := manager.Create( - context.Background(), - "probe", - "Probe", - "legacy-agent", - t.TempDir(), - "legacy-agent", - nil, - sessionpkg.ProviderResume{ + manager := sessionpkg.NewManagerWithOptions(store, sp) + + info, err := manager.CreateSession( + context.Background(), sessionpkg.CreateOptions{Template: "probe", Title: "Probe", Command: "legacy-agent", WorkDir: t.TempDir(), Provider: "legacy-agent", Env: nil, Resume: sessionpkg.ProviderResume{ ResumeFlag: "--old-resume", ResumeStyle: "flag", SessionIDFlag: "--session-id", - }, - runtime.Config{}, - ) + }, Hints: runtime.Config{}, ExtraMeta: map[string]string{"session_origin": "manual"}}) if err != nil { t.Fatalf("Create: %v", err) } @@ -2047,7 +2038,7 @@ func newTestSessionHandleWithRecorder(t *testing.T, spec SessionSpec, recorder e store := beads.NewMemStore() sp := runtime.NewFake() - manager := sessionpkg.NewManager(store, sp) + manager := sessionpkg.NewManagerWithOptions(store, sp) handle, err := NewSessionHandle(SessionHandleConfig{ Manager: manager, Recorder: recorder, diff --git a/internal/worker/invocation_telemetry_label_test.go b/internal/worker/invocation_telemetry_label_test.go index 1ff5f8b39c..79eff2e052 100644 --- a/internal/worker/invocation_telemetry_label_test.go +++ b/internal/worker/invocation_telemetry_label_test.go @@ -27,7 +27,7 @@ func TestMessageRecordsNormalizedProviderFamilyLabel(t *testing.T) { workDir := t.TempDir() store := beads.NewMemStore() sp := runtime.NewFake() - manager := sessionpkg.NewManager(store, sp) + manager := sessionpkg.NewManagerWithOptions(store, sp) // No Profile is set, so the label/pricing derivation cannot lean on // profileFamily and must normalize the claude-family alias provider itself. diff --git a/internal/worker/invocation_telemetry_test.go b/internal/worker/invocation_telemetry_test.go index bc6f12d114..2ef41227ae 100644 --- a/internal/worker/invocation_telemetry_test.go +++ b/internal/worker/invocation_telemetry_test.go @@ -50,7 +50,7 @@ func newInvocationTelemetryHandle(t *testing.T) (*SessionHandle, *beads.MemStore workDir := t.TempDir() store := beads.NewMemStore() sp := runtime.NewFake() - manager := sessionpkg.NewManager(store, sp) + manager := sessionpkg.NewManagerWithOptions(store, sp) handle, err := NewSessionHandle(SessionHandleConfig{ Manager: manager, SearchPaths: []string{searchBase}, @@ -501,7 +501,7 @@ func newFamilyTelemetryHandle(t *testing.T, profile Profile, provider, command s workDir := t.TempDir() store := beads.NewMemStore() sp := runtime.NewFake() - manager := sessionpkg.NewManager(store, sp) + manager := sessionpkg.NewManagerWithOptions(store, sp) handle, err := NewSessionHandle(SessionHandleConfig{ Manager: manager, SearchPaths: []string{searchBase}, diff --git a/internal/worker/invocation_telemetry_usagefact_test.go b/internal/worker/invocation_telemetry_usagefact_test.go index b64131571e..f1e4219a23 100644 --- a/internal/worker/invocation_telemetry_usagefact_test.go +++ b/internal/worker/invocation_telemetry_usagefact_test.go @@ -28,7 +28,7 @@ func newUsageFactHandle(t *testing.T) (handle *SessionHandle, transcriptPath, si store := beads.NewMemStore() sp := runtime.NewFake() - manager := sessionpkg.NewManager(store, sp) + manager := sessionpkg.NewManagerWithOptions(store, sp) h, err := NewSessionHandle(SessionHandleConfig{ Manager: manager, SearchPaths: []string{searchBase}, diff --git a/internal/worker/workertest/telemetry_handle_conformance_test.go b/internal/worker/workertest/telemetry_handle_conformance_test.go index ccbbfb9c0b..b57a877b50 100644 --- a/internal/worker/workertest/telemetry_handle_conformance_test.go +++ b/internal/worker/workertest/telemetry_handle_conformance_test.go @@ -93,7 +93,7 @@ func sessionHandleRecordedInputTokens(t *testing.T) int64 { workDir := t.TempDir() store := beads.NewMemStore() sp := runtime.NewFake() - manager := sessionpkg.NewManager(store, sp) + manager := sessionpkg.NewManagerWithOptions(store, sp) handle, err := worker.NewSessionHandle(worker.SessionHandleConfig{ Manager: manager, diff --git a/test/acceptance/worker_inference/worker_handle_live_helpers_test.go b/test/acceptance/worker_inference/worker_handle_live_helpers_test.go index 04574a3fc5..6c07d14445 100644 --- a/test/acceptance/worker_inference/worker_handle_live_helpers_test.go +++ b/test/acceptance/worker_inference/worker_handle_live_helpers_test.go @@ -129,7 +129,7 @@ func newLiveWorkerHandleHarness(t *testing.T) (*liveWorkerHandleHarness, error) tmuxCfg.SocketName = socketName provider := runtimetmux.NewProviderWithConfig(tmuxCfg) - manager := sessionpkg.NewManager(store, provider) + manager := sessionpkg.NewManagerWithOptions(store, provider) sessionEnv := mergeStringMaps(envMapFromAcceptanceEnv(env), resolved.Env) handle, err := workerpkg.NewSessionHandle(workerpkg.SessionHandleConfig{ Manager: manager, From 14d1dbf608f0f90a8887ebae19aee161aec326ad Mon Sep 17 00:00:00 2001 From: Stephanie Jarmak <36544495+sjarmak@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:02:17 -0400 Subject: [PATCH 031/225] fix(tmux): prime empty snapshot on unprimed no-server; address post-merge review (#4086) ## Why Post-merge review follow-up addressing valid Copilot comments on #4082 and #4083. ## What changed - **Prime an empty snapshot on an unprimed no-server state** (`internal/runtime/tmux/state_cache.go`). #4082 made `FetchState` return `ErrRuntimeUnavailable` on `ErrNoServer` so `refresh()` preserves last-known-good. That is correct once the cache has been primed, but on an unprimed cache (a fresh city with no tmux server yet) the failure path never sets `fetchedAt`, so `currentState()` re-enters `refresh()` and re-spawns `list-panes` plus re-logs on every `IsRunning()` call. The fix splits the failure path on `fetchedAt.IsZero()`: an unprimed genuine no-server initializes an empty non-nil snapshot (primes the cache, reports all sessions not-running, holds until the TTL), while a primed-then-unreachable cache still preserves last-known-good to the `staleTTL` cliff. The zero-check is the discriminator, so a mid-life blip can never take the prime-empty branch. - **Deterministic cache-TTL test** (`state_cache_test.go`). A test used `time.Nanosecond` as the TTL to force a refresh, but `time.Since(fetchedAt)` can read `0` on the next call, so `0 < 1ns` was a cache hit and the second read skipped the refresh (flaky). Changed to TTL `0`, which makes `time.Since < 0` always false and forces the refresh unconditionally. - **Neutral role terms in reconciler comments** (`cmd/gc/idle_nudge.go`, `city_runtime.go`, `city_runtime_test.go`). The zero-hardcoded-roles invariant (AGENTS.md) applies to Go source; three comments naming a specific role are reworded to "worker session" / "pool slot" / "warm pool worker". Comment-only, no behavior change. ## Test plan - `go build ./...`, `go vet ./internal/runtime/... ./cmd/gc/...`: clean - `go test ./internal/runtime/tmux/ ./cmd/gc/ -run 'StateCache|NoServer|StaleTTL|IdleClaimNudge'`: pass, including a new `TestStateCache_UnprimedNoServerPrimesEmptyWithoutRefetch` asserting exactly one `list-panes` call across three `IsRunning()` reads on an unprimed no-server cache - New + fixed tests at `-count=100 -race`: clean --------- Co-authored-by: sjarmak --- cmd/gc/city_runtime.go | 2 +- cmd/gc/city_runtime_test.go | 4 +- cmd/gc/idle_nudge.go | 6 +-- internal/runtime/tmux/state_cache.go | 23 ++++++++++- internal/runtime/tmux/state_cache_test.go | 48 +++++++++++++++++++++-- 5 files changed, 73 insertions(+), 10 deletions(-) diff --git a/cmd/gc/city_runtime.go b/cmd/gc/city_runtime.go index eaa85d1850..b15b854c26 100644 --- a/cmd/gc/city_runtime.go +++ b/cmd/gc/city_runtime.go @@ -2360,7 +2360,7 @@ func (cr *CityRuntime) beadReconcileTick(ctx context.Context, result DesiredStat // Activity reporting lets the controller SEE such a slot as alive but never // delivers the claim nudge, so tmux has no demand-driven wake for it. The // backstop is churn-free by construction for either runtime: it keys on the - // trigger bead still being open (the instant a polecat claims, the bead + // trigger bead still being open (the instant a pool slot claims, the bead // flips to in_progress and stops matching), persists its bounded // observe→nudge→backoff state on the session bead, and never spams a tick. // See nudgeStalledPoolClaims for the full invariant. diff --git a/cmd/gc/city_runtime_test.go b/cmd/gc/city_runtime_test.go index 09ac0ff6e8..49d31a85ad 100644 --- a/cmd/gc/city_runtime_test.go +++ b/cmd/gc/city_runtime_test.go @@ -3096,8 +3096,8 @@ func TestCityRuntimeBeadReconcileTick_IdleClaimNudgeRunsForReportActivityRuntime State: map[string]TemplateParams{}, ScaleCheckCounts: map[string]int{"worker": 0}, AssignedWorkBeads: []beads.Bead{ - // Open + unassigned == unclaimed: the slot's trigger bead the polecat - // never began. workBead sets gc.routed_to but leaves the assignee empty. + // Open + unassigned == unclaimed: the slot's trigger bead the warm pool + // worker never began. workBead sets gc.routed_to but leaves the assignee empty. workBead("w-idle", "worker", "", "open", 5), }, } diff --git a/cmd/gc/idle_nudge.go b/cmd/gc/idle_nudge.go index 534c2247b1..a591303b7e 100644 --- a/cmd/gc/idle_nudge.go +++ b/cmd/gc/idle_nudge.go @@ -37,7 +37,7 @@ const ( // is running but whose assigned trigger bead is still UNCLAIMED (open, not // in_progress). The startup nudge can be missed — a freshly-spawned slot whose // submit-CR was swallowed, or a warm slot that survived a `gc restart` and was -// never re-Started — leaving the polecat idle at its prompt with work it never +// never re-Started — leaving the worker session idle at its prompt with work it never // began. tmux's relaunch/respawn path only heals a session that DIED; a live // idle slot needs this demand-driven wake exactly as herdr does (activity // reporting makes the controller SEE the slot but never nudges it to claim). @@ -53,7 +53,7 @@ const ( // Churn-free by construction — it inverts every failure mode that got the #312 // idle-session nudger reverted: // - Keys on bead state (trigger bead == open), never "idle for N minutes", so -// it is structurally invisible to a working agent: the instant a polecat +// it is structurally invisible to a working agent: the instant a pool slot // claims, its trigger bead flips to in_progress and stops matching. // - State is persisted on the session bead, so a restart cannot replay it. // - Bounded per assignment: observe (grace) → nudge → backoff retries → give @@ -149,7 +149,7 @@ func isUnclaimedTrigger(w beads.Bead, sessName string) bool { return true } -// claimNudgeFor resolves the slot's configured startup nudge (the polecat's +// claimNudgeFor resolves the slot's configured startup nudge (the worker's // `gc hook --claim` line) from the agent template behind this session bead. func claimNudgeFor(cfg *config.City, session beads.Bead) string { template := normalizedSessionTemplate(session, cfg) diff --git a/internal/runtime/tmux/state_cache.go b/internal/runtime/tmux/state_cache.go index 94183488d6..2406459339 100644 --- a/internal/runtime/tmux/state_cache.go +++ b/internal/runtime/tmux/state_cache.go @@ -192,8 +192,29 @@ func (c *StateCache) refresh() { log.Printf("tmux state cache: refresh failed in %v: %v", elapsed, err) c.mu.Lock() c.lastError = err + // Two distinct failure regimes, keyed on whether the cache was ever + // primed (fetchedAt set by a prior success): + // + // UNPRIMED + genuine no-server (a fresh city with no tmux server + // yet): initialize to an EMPTY snapshot so the cache is primed. + // Without this, currentState() sees a nil Sessions map and forces + // a fresh list-panes spawn plus a failure log on EVERY IsRunning() + // call — a re-spawn/log storm in the exact steady state (no server) + // where nothing will change until one is started. An empty primed + // snapshot correctly reports all sessions not-running and holds as + // a cache hit until the TTL lapses. + // + // PRIMED then now-unreachable: preserve last-known-good (do NOT + // touch fetchedAt or sessions) until the staleTTL cliff. A server + // that was up then briefly vanished (supervisor restart, socket + // stall) must not wipe a good snapshot and drain healthy pool slots + // — that is #4082's intent. + if c.fetchedAt.IsZero() && isNoServerError(err) { + c.state = runtimeStateSnapshot{Sessions: make(map[string]sessionRuntimeState)} + c.fetchedAt = time.Now() + c.dirty = false + } c.mu.Unlock() - // Preserve last-known-good — do NOT update fetchedAt or sessions. return nil, err } diff --git a/internal/runtime/tmux/state_cache_test.go b/internal/runtime/tmux/state_cache_test.go index 4e9ca40427..0ce7834ec1 100644 --- a/internal/runtime/tmux/state_cache_test.go +++ b/internal/runtime/tmux/state_cache_test.go @@ -342,13 +342,17 @@ func TestStateCache_NoServerRefreshPreservesLastKnownGood(t *testing.T) { outs: []string{"agent-1\t0\tclaude\t123"}, errs: []error{nil, ErrNoServer, ErrNoServer, ErrNoServer}, } - cache := NewStateCache(&tmuxFetcher{tm: &Tmux{cfg: DefaultConfig(), exec: fe}}, time.Nanosecond) + // TTL 0 forces every read to refresh unconditionally (time.Since(fetchedAt) + // is never < 0). A nanosecond TTL is non-deterministic here: on a coarse + // monotonic clock time.Since can read 0 on the very next call, so the second + // IsRunning may skip the refresh and leave lastError nil (flaky). + cache := NewStateCache(&tmuxFetcher{tm: &Tmux{cfg: DefaultConfig(), exec: fe}}, 0) if !cache.IsRunning("agent-1") { t.Fatal("expected agent-1 running after prime") } - // TTL is a nanosecond, so the next read forces a refresh that hits - // ErrNoServer. Last-known-good must survive it (staleTTL default 30s). + // TTL 0, so the next read forces a refresh that hits ErrNoServer. + // Last-known-good must survive it (staleTTL default 30s). if !cache.IsRunning("agent-1") { t.Error("expected agent-1 still running after an ErrNoServer refresh (last-known-good); a brief tmux outage must not report sessions as gone") } @@ -360,6 +364,44 @@ func TestStateCache_NoServerRefreshPreservesLastKnownGood(t *testing.T) { } } +// An UNPRIMED cache (never held a good state, fetchedAt zero) that hits a +// genuine "no server" must prime itself to an empty snapshot rather than +// re-spawning list-panes and re-logging the failure on every IsRunning. A +// fresh city with no tmux server yet would otherwise storm the (absent) server +// with one list-panes per liveness probe. +func TestStateCache_UnprimedNoServerPrimesEmptyWithoutRefetch(t *testing.T) { + fe := &fakeExecutor{ + // Every list-panes reports no server; the cache is never primed good. + errs: []error{ErrNoServer, ErrNoServer, ErrNoServer, ErrNoServer}, + } + // A real TTL (not 0) so a successfully primed empty snapshot is a cache hit + // on the next read — proving priming stops the refetch storm. + cache := NewStateCache(&tmuxFetcher{tm: &Tmux{cfg: DefaultConfig(), exec: fe}}, time.Second) + + if cache.IsRunning("agent-1") { + t.Fatal("expected agent-1 not running against a server-less city") + } + // The first read primed an empty snapshot with a single list-panes spawn. + // Every subsequent read within the TTL must be a cache hit — no refetch. + _ = cache.IsRunning("agent-1") + _ = cache.IsRunning("agent-2") + if calls := len(fe.calls); calls != 1 { + t.Fatalf("list-panes calls = %d, want 1: an unprimed no-server must prime empty once, not refetch on every IsRunning", calls) + } + + // The cache is primed: fetchedAt set, and the failure recorded in lastError. + cache.mu.RLock() + fetchedAt := cache.fetchedAt + lastErr := cache.lastError + cache.mu.RUnlock() + if fetchedAt.IsZero() { + t.Error("expected fetchedAt to be set (cache primed) after an unprimed no-server refresh") + } + if !errors.Is(lastErr, gcruntime.ErrRuntimeUnavailable) { + t.Errorf("cache.lastError = %v, want errors.Is(runtime.ErrRuntimeUnavailable)", lastErr) + } +} + func TestStateCache_RefreshFailurePreservesLastKnownGood(t *testing.T) { f := &mockFetcher{ sessions: map[string]bool{"agent-1": true}, From 3c8404a18fcaaa08df1dd1e266e85ed78ca6ec2b Mon Sep 17 00:00:00 2001 From: Stephanie Jarmak <36544495+sjarmak@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:16:12 -0400 Subject: [PATCH 032/225] fix(dispatch): pass ambient Dolt connection env into control-ready query (#4108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes the control-dispatcher's `bd --sandbox ready` work-query losing its Dolt connection env intermittently, which resolves the port to `0` and produces the recurring fleet-wide `graph.v2` dispatcher wedge (`Dolt server unreachable at 127.0.0.1:0`). ## Root cause The dispatcher's work-query subprocess env is rebuilt via `mergeRuntimeEnv`/`controllerWorkQueryEnv`, which re-resolves Dolt connection coordinates from a managed-runtime-availability probe. That resolution can transiently come back without a port, silently stripping `GC_DOLT_PORT`/`BEADS_DOLT_SERVER_PORT` from the subprocess env, so `bd --sandbox` falls back to port `0`. The running dispatcher process itself always has the correct host/port — this pipes them through explicitly as a shell-prefix env assignment on the `bd --sandbox ready` invocation so the work-query no longer depends on the probe succeeding on every poll. Fixed in `cmd/gc/dispatch_runtime.go` (`workflowServeControlReadyQueryForBeads`). ## Testing - `make check` (simplify → review → contributor-check pipeline, ship-pass sentinel `verdict: ready`) - Added an execution-level regression test for the fix - `62aaa7bb2` resolves ambient Dolt host/port as a matched pair (avoids a mismatched-pair edge case found in review) ## Impact This is the root cause tracked as gc-74rxa; it has been the single blocker holding P1 security issue #2723 (Host-header allowlist / DNS rebinding) and every `graph.v2` author-from-issue sling since 2026-07-06 (see rollup gc-ie14l for the stranded-workflow trail this caused). --------- Co-authored-by: sjarmak --- cmd/gc/cmd_convoy_dispatch_test.go | 131 +++++++++++++++++++++++++++++ cmd/gc/dispatch_runtime.go | 54 +++++++++++- 2 files changed, 184 insertions(+), 1 deletion(-) diff --git a/cmd/gc/cmd_convoy_dispatch_test.go b/cmd/gc/cmd_convoy_dispatch_test.go index 8700ceb3f4..0df8dd1915 100644 --- a/cmd/gc/cmd_convoy_dispatch_test.go +++ b/cmd/gc/cmd_convoy_dispatch_test.go @@ -2976,6 +2976,137 @@ func TestWorkflowServeControlReadyQueryUsesControlTiers(t *testing.T) { } } +// TestWorkflowServeControlReadyQueryPassesThroughAmbientDoltPort guards +// against gc-74rxa: the ready-query subprocess env is otherwise rebuilt via +// mergeRuntimeEnv/controllerWorkQueryEnv, which can transiently resolve +// without a Dolt port and silently drop GC_DOLT_PORT/BEADS_DOLT_SERVER_PORT, +// causing `bd --sandbox` to fall back to port 0. The dispatcher process's own +// environment already carries the correct connection coordinates it was +// spawned with, so the query string must carry them through explicitly. +func TestWorkflowServeControlReadyQueryPassesThroughAmbientDoltPort(t *testing.T) { + t.Setenv("GC_DOLT_HOST", "127.0.0.1") + t.Setenv("GC_DOLT_PORT", "29620") + unsetTestEnv(t, "BEADS_DOLT_SERVER_HOST", "BEADS_DOLT_SERVER_PORT") + + query := workflowServeControlReadyQuery(config.Agent{Name: config.ControlDispatcherAgentName}) + + for _, want := range []string{ + "GC_DOLT_HOST='127.0.0.1'", + "BEADS_DOLT_SERVER_HOST='127.0.0.1'", + "GC_DOLT_PORT='29620'", + "BEADS_DOLT_SERVER_PORT='29620'", + } { + if !strings.Contains(query, want) { + t.Fatalf("workflowServeControlReadyQuery missing %q in %q", want, query) + } + } +} + +// TestWorkflowServeControlReadyQueryOmitsDoltEnvWhenAmbientUnset ensures the +// query stays clean (no bare "KEY=" assignments) when the current process has +// no Dolt connection env at all (e.g. a doltlite-backed scope). +func TestWorkflowServeControlReadyQueryOmitsDoltEnvWhenAmbientUnset(t *testing.T) { + unsetTestEnv(t, "GC_DOLT_HOST", "GC_DOLT_PORT", "BEADS_DOLT_SERVER_HOST", "BEADS_DOLT_SERVER_PORT") + + query := workflowServeControlReadyQuery(config.Agent{Name: config.ControlDispatcherAgentName}) + + for _, unwanted := range []string{"GC_DOLT_HOST=", "GC_DOLT_PORT=", "BEADS_DOLT_SERVER_HOST=", "BEADS_DOLT_SERVER_PORT="} { + if strings.Contains(query, unwanted) { + t.Fatalf("workflowServeControlReadyQuery should omit %q when ambient env is unset: %q", unwanted, query) + } + } +} + +// TestWorkflowServeControlReadyQueryDoesNotMixDoltNamespaces guards against a +// correctness gap found in cross-provider review of gc-74rxa: host and port +// must resolve as a matched pair from one env-var namespace, never as a host +// from GC_DOLT_* combined with a port from BEADS_DOLT_SERVER_* (or vice +// versa) -- a combination that may never have described the same server. +// Here GC_DOLT_PORT is set (so the GC_DOLT_* namespace is "in use" for this +// process) while only BEADS_DOLT_SERVER_HOST carries a value; the stale +// BEADS host must NOT leak into the query paired with the GC port. +func TestWorkflowServeControlReadyQueryDoesNotMixDoltNamespaces(t *testing.T) { + unsetTestEnv(t, "GC_DOLT_HOST") + t.Setenv("GC_DOLT_PORT", "29999") + t.Setenv("BEADS_DOLT_SERVER_HOST", "9.9.9.9") + unsetTestEnv(t, "BEADS_DOLT_SERVER_PORT") + + query := workflowServeControlReadyQuery(config.Agent{Name: config.ControlDispatcherAgentName}) + + for _, want := range []string{"GC_DOLT_PORT='29999'", "BEADS_DOLT_SERVER_PORT='29999'"} { + if !strings.Contains(query, want) { + t.Fatalf("workflowServeControlReadyQuery missing %q in %q", want, query) + } + } + if strings.Contains(query, "9.9.9.9") { + t.Fatalf("workflowServeControlReadyQuery must not mix BEADS_DOLT_SERVER_HOST from a different namespace than the resolved port: %q", query) + } +} + +// unsetTestEnv unsets the given env vars for the duration of the test, +// restoring the original values (or absence) afterward. +func unsetTestEnv(t *testing.T, keys ...string) { + t.Helper() + for _, key := range keys { + t.Setenv(key, "") + _ = os.Unsetenv(key) + } +} + +// TestWorkflowServeControlReadyQueryDeliversAmbientDoltPortAtExecution is the +// execution-level companion to TestWorkflowServeControlReadyQueryPassesThroughAmbientDoltPort: +// cross-provider review of gc-74rxa noted that a pure string-assertion test +// can pass while the real runtime path (shellWorkQueryWithEnv running the +// query via `sh -c`, cmd/gc/cmd_hook.go:555) stays broken, since it never +// crosses the process boundary. This test runs the built query through a +// fake `bd` with an OUTER env that deliberately carries no Dolt connection +// vars at all -- reproducing the exact failure mode (mergeRuntimeEnv having +// stripped them) -- and asserts bd still receives the ambient port via the +// query string's own shell-prefix assignment. +func TestWorkflowServeControlReadyQueryDeliversAmbientDoltPortAtExecution(t *testing.T) { + t.Setenv("GC_DOLT_HOST", "127.0.0.1") + t.Setenv("GC_DOLT_PORT", "29620") + unsetTestEnv(t, "BEADS_DOLT_SERVER_HOST", "BEADS_DOLT_SERVER_PORT") + + query := workflowServeControlReadyQuery( + config.Agent{Name: config.ControlDispatcherAgentName, Dir: "gascity"}, + "gascity--control-dispatcher", + ) + + tmp := t.TempDir() + logPath := filepath.Join(tmp, "bd.log") + bdPath := filepath.Join(tmp, "bd") + if err := os.WriteFile(bdPath, []byte(`#!/bin/sh +set -eu +printf 'GC_DOLT_PORT=%s BEADS_DOLT_SERVER_PORT=%s\n' "${GC_DOLT_PORT:-}" "${BEADS_DOLT_SERVER_PORT:-}" >> "$BD_LOG" +printf '[]' +`), 0o755); err != nil { + t.Fatalf("write fake bd: %v", err) + } + + // The outer env passed to shellWorkQueryWithEnv has no GC_DOLT_*/ + // BEADS_DOLT_SERVER_* at all -- simulating mergeRuntimeEnv/ + // controllerWorkQueryEnv having dropped them. Without the fix, bd would + // see an empty port here and resolve :0. + _, err := shellWorkQueryWithEnv(query, t.TempDir(), []string{ + "PATH=" + tmp + string(os.PathListSeparator) + os.Getenv("PATH"), + "BD_LOG=" + logPath, + "GC_SESSION_NAME=gascity--control-dispatcher", + "GC_ALIAS=gascity/control-dispatcher", + }) + if err != nil { + t.Fatalf("run workflow serve query: %v", err) + } + + logData, readErr := os.ReadFile(logPath) + if readErr != nil { + t.Fatalf("read bd log: %v", readErr) + } + if !strings.Contains(string(logData), "GC_DOLT_PORT=29620") || !strings.Contains(string(logData), "BEADS_DOLT_SERVER_PORT=29620") { + t.Fatalf("bd did not see the ambient Dolt port despite a stripped outer env; log:\n%s", string(logData)) + } +} + func TestWorkflowServeWorkQueryRecognizesCoreControlDispatcher(t *testing.T) { query := workflowServeWorkQuery(config.Agent{Name: "core.control-dispatcher", Dir: "fixture"}) diff --git a/cmd/gc/dispatch_runtime.go b/cmd/gc/dispatch_runtime.go index f1a57afcc4..f7e5b29dff 100644 --- a/cmd/gc/dispatch_runtime.go +++ b/cmd/gc/dispatch_runtime.go @@ -785,7 +785,7 @@ func workflowServeControlReadyQueryForBeads(agentCfg config.Agent, beadsCfg conf jqFilter = strings.ReplaceAll(jqFilter, `\`, `\\`) jqFilter = strings.ReplaceAll(jqFilter, `"`, `\"`) jqFilter = strings.ReplaceAll(jqFilter, `$`, `\$`) - queryPrefix := `BD_EXPORT_AUTO=false GC_CONTROL_TARGET=` + shellquote.Quote(target) + queryPrefix := `BD_EXPORT_AUTO=false GC_CONTROL_TARGET=` + shellquote.Quote(target) + ambientDoltConnectionQueryPrefix() for _, name := range controlSessionNames { name = strings.TrimSpace(name) if name == "" { @@ -825,6 +825,58 @@ func workflowServeControlReadyQueryForBeads(agentCfg config.Agent, beadsCfg conf return query } +// ambientDoltConnectionQueryPrefix returns a shell-prefix env fragment +// (leading space + "KEY=value" pairs, or "") carrying the CURRENT process's +// Dolt connection coordinates under both the GC_DOLT_* and BEADS_DOLT_SERVER_* +// names bd recognizes. +// +// Without this, the ready-query subprocess env is built by stripping the +// parent's inherited Dolt vars and re-projecting them from a freshly resolved +// scope lookup (mergeRuntimeEnv + controllerWorkQueryEnv). That resolution +// runs its own managed-runtime-availability probe and can transiently come +// back without a port, silently dropping GC_DOLT_PORT/BEADS_DOLT_SERVER_PORT +// from the subprocess env and causing `bd --sandbox` to resolve port 0 +// ("Dolt server unreachable at 127.0.0.1:0") — the recurring fleet-wide +// graph.v2 wedge (gascity gc-74rxa). The running control-dispatcher process's +// own environment already carries the connection coordinates it was spawned +// with, so pass them through explicitly as a shell-prefix assignment (which +// takes effect for the inner `sh -c` and its `bd` children regardless of what +// the outer subprocess's cmd.Env resolved to) rather than depending on that +// re-resolution succeeding on every poll. +func ambientDoltConnectionQueryPrefix() string { + host, port := ambientDoltHostPort() + var pairs []string + if host != "" { + quotedHost := shellquote.Quote(host) + pairs = append(pairs, `GC_DOLT_HOST=`+quotedHost, `BEADS_DOLT_SERVER_HOST=`+quotedHost) + } + if port != "" { + quotedPort := shellquote.Quote(port) + pairs = append(pairs, `GC_DOLT_PORT=`+quotedPort, `BEADS_DOLT_SERVER_PORT=`+quotedPort) + } + if len(pairs) == 0 { + workflowTracef("ambient dolt env unset; ready-query passthrough disabled") + return "" + } + return " " + strings.Join(pairs, " ") +} + +// ambientDoltHostPort resolves the ambient Dolt host and port as a matched +// pair from a single env-var namespace instead of choosing each field +// independently. GC_DOLT_* is authoritative when present (even partially); +// BEADS_DOLT_SERVER_* is only consulted as a whole-pair fallback when +// GC_DOLT_* carries neither value. Resolving fields independently risked +// pairing a host from one namespace with a port from the other -- a +// combination that may never have described the same server. +func ambientDoltHostPort() (host, port string) { + host = strings.TrimSpace(os.Getenv("GC_DOLT_HOST")) + port = strings.TrimSpace(os.Getenv("GC_DOLT_PORT")) + if host != "" || port != "" { + return host, port + } + return strings.TrimSpace(os.Getenv("BEADS_DOLT_SERVER_HOST")), strings.TrimSpace(os.Getenv("BEADS_DOLT_SERVER_PORT")) +} + func workflowServeLegacyControlRoute(target string) string { target = strings.TrimSpace(target) if target == config.ControlDispatcherAgentName { From 359bf45f8c49639e09d572523277d80987a0f31b Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Thu, 9 Jul 2026 11:58:30 -0700 Subject: [PATCH 033/225] simplify(S01) phase 1: beads cache absorb/evict primitives (#4046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.8 (1M context) --- internal/beads/caching_store.go | 141 +++++- internal/beads/caching_store_events.go | 39 +- internal/beads/caching_store_graph_apply.go | 12 +- .../beads/caching_store_primitives_test.go | 469 ++++++++++++++++++ internal/beads/caching_store_reads.go | 69 +-- internal/beads/caching_store_reconcile.go | 21 +- internal/beads/caching_store_writes.go | 199 ++++---- 7 files changed, 761 insertions(+), 189 deletions(-) create mode 100644 internal/beads/caching_store_primitives_test.go diff --git a/internal/beads/caching_store.go b/internal/beads/caching_store.go index 30dc9d15b2..d6604d2a12 100644 --- a/internal/beads/caching_store.go +++ b/internal/beads/caching_store.go @@ -367,6 +367,124 @@ func (c *CachingStore) noteLocalMutationLocked(ids ...string) uint64 { return seq } +// absorbDepsMode selects how absorbFreshLocked sources the deps row for a bead. +type absorbDepsMode int + +const ( + // depsExplicit installs the caller-supplied opts.deps (cloned). + depsExplicit absorbDepsMode = iota + // depsFromFields recomputes deps from the bead's own fields, unconditionally + // (setting a nil entry when the bead carries no dependency fields). + depsFromFields + // depsFromFieldsIfCarried recomputes deps from the bead's fields only when + // the bead carries dependency fields; otherwise the cached deps row is left + // untouched. + depsFromFieldsIfCarried + // depsKeepCached leaves the cached deps row untouched. + depsKeepCached + // depsDrop removes the deps row. + depsDrop +) + +// absorbSeqMode selects how absorbFreshLocked treats the beadSeq/localBeadAt +// staleness fences for a bead. +type absorbSeqMode int + +const ( + // seqKeep touches neither fence. Used by write/event paths that ran + // noteMutationLocked/noteLocalMutationLocked immediately before the absorb + // and MUST preserve the fence they just set (the #2210 staleness defense). + seqKeep absorbSeqMode = iota + // seqClearGuarded clears both fences unless a recent local write-through is + // still inside the recency window. + seqClearGuarded + // seqClearBeadSeqOnly clears the beadSeq fence unconditionally and leaves + // localBeadAt untouched. + seqClearBeadSeqOnly +) + +// absorbOpts describes the two axes of variation observed across the cache's +// absorb sites: how the deps row is sourced and how the staleness fences are +// treated. clearDirty is separate because a small number of sites (prime's +// slow path, PrimeActive) deliberately leave a dirty mark in place across an +// absorb. +type absorbOpts struct { + depsMode absorbDepsMode + deps []Dep // consulted only for depsExplicit + seqMode absorbSeqMode + clearDirty bool +} + +// absorbFreshLocked installs a fresh row for id per opts. It is the only code +// that installs a cached row alongside clearing the row's tombstone/staleness +// state. now is the caller's clock read for the whole pass; it is consulted +// only by seqClearGuarded. Caller must hold c.mu in write mode. +func (c *CachingStore) absorbFreshLocked(id string, bead Bead, now time.Time, opts absorbOpts) { + c.beads[id] = cloneBead(bead) + switch opts.depsMode { + case depsExplicit: + c.deps[id] = cloneDeps(opts.deps) + case depsFromFields: + c.deps[id] = depsFromBeadFields(bead) + case depsFromFieldsIfCarried: + if beadCarriesDependencyFields(bead) { + c.deps[id] = depsFromBeadFields(bead) + } + case depsKeepCached: + // leave c.deps[id] untouched + case depsDrop: + delete(c.deps, id) + } + if opts.clearDirty { + delete(c.dirty, id) + } + delete(c.deletedSeq, id) + switch opts.seqMode { + case seqClearGuarded: + if !recentLocalMutation(c.localBeadAt[id], now) { + delete(c.beadSeq, id) + delete(c.localBeadAt, id) + } + case seqClearBeadSeqOnly: + delete(c.beadSeq, id) + } +} + +// evictLocked removes every trace of id from the six per-row maps. It does not +// touch mutationSeq, depsComplete, state, or stats. Caller must hold c.mu in +// write mode. +func (c *CachingStore) evictLocked(id string) { + delete(c.beads, id) + delete(c.deps, id) + delete(c.dirty, id) + delete(c.deletedSeq, id) + delete(c.beadSeq, id) + delete(c.localBeadAt, id) +} + +// tombstoneLocked evicts id and installs a deletion fence at seq. seq must be a +// mutationSeq value obtained under the same lock hold so the fence exceeds any +// startSeq captured before this section. Caller must hold c.mu in write mode. +func (c *CachingStore) tombstoneLocked(id string, seq uint64) { + c.evictLocked(id) + c.deletedSeq[id] = seq +} + +// markDirtyLocked flags id as known-stale so reads bypass the cache until a +// refresh clears the mark. Caller must hold c.mu in write mode. +func (c *CachingStore) markDirtyLocked(id string) { + c.dirty[id] = struct{}{} +} + +// clearStalenessMarksLocked clears the dirty flag and deletion fence for id +// without touching the cached row or its deps. Used by the deps-overlay +// fallbacks that trust an in-place dependency mutation. Caller must hold c.mu +// in write mode. +func (c *CachingStore) clearStalenessMarksLocked(id string) { + delete(c.dirty, id) + delete(c.deletedSeq, id) +} + // PrimeActive loads the common active bead statuses (open + in_progress) across // both persistent issues and ephemeral wisps into the cache. These are fast indexed // queries that populate enough data for @@ -423,17 +541,14 @@ func (c *CachingStore) PrimeActive() error { if _, keep := c.recentLocalBeadConflictLocked(b.ID, b, now, false); keep { continue } - c.beads[b.ID] = cloneBead(b) + opts := absorbOpts{seqMode: seqClearGuarded, clearDirty: false} if depsComplete && depErr == nil { - c.deps[b.ID] = cloneDeps(depMap[b.ID]) + opts.depsMode = depsExplicit + opts.deps = depMap[b.ID] } else { - c.deps[b.ID] = depsFromBeadFields(b) - } - delete(c.deletedSeq, b.ID) - if !recentLocalMutation(c.localBeadAt[b.ID], now) { - delete(c.beadSeq, b.ID) - delete(c.localBeadAt, b.ID) + opts.depsMode = depsFromFields } + c.absorbFreshLocked(b.ID, b, now, opts) } if c.state == cacheUninitialized { c.state = cachePartial @@ -576,14 +691,14 @@ func (c *CachingStore) prime(ctx context.Context) error { if _, exists := c.beads[id]; exists { continue } - c.beads[id] = b - delete(c.deletedSeq, id) - delete(c.beadSeq, id) + opts := absorbOpts{seqMode: seqClearBeadSeqOnly, clearDirty: false} if depsComplete && depErr == nil { - c.deps[id] = cloneDeps(depMap[id]) + opts.depsMode = depsExplicit + opts.deps = depMap[id] } else { - c.deps[id] = depsFromBeadFields(b) + opts.depsMode = depsFromFields } + c.absorbFreshLocked(id, b, now, opts) } c.depsComplete = false } diff --git a/internal/beads/caching_store_events.go b/internal/beads/caching_store_events.go index c4a1d90c26..d51db6e38c 100644 --- a/internal/beads/caching_store_events.go +++ b/internal/beads/caching_store_events.go @@ -112,7 +112,7 @@ func (c *CachingStore) ApplyEvent(eventType string, payload json.RawMessage) { // backing and are intentionally tolerated without declining. if fieldConflictCached { c.mu.Lock() - c.dirty[patch.ID] = struct{}{} + c.markDirtyLocked(patch.ID) c.mu.Unlock() } return @@ -217,10 +217,14 @@ func (c *CachingStore) ApplyEvent(eventType string, payload json.RawMessage) { case "bead.created": if _, exists := c.beads[b.ID]; !exists { c.noteMutationLocked(b.ID) - c.beads[b.ID] = cloneBead(b) + // OC-3: absorb installs the row before updateEventDepsLocked, whose + // clearReadyProjectionLocked must observe the newly absorbed row. + c.absorbFreshLocked(b.ID, b, time.Now(), absorbOpts{ + depsMode: depsKeepCached, + seqMode: seqKeep, + clearDirty: true, + }) c.updateEventDepsLocked(eventType, b, fields, refreshedFromBacking) - delete(c.dirty, b.ID) - delete(c.deletedSeq, b.ID) } c.updateStatsLocked() mutated = true @@ -231,9 +235,11 @@ func (c *CachingStore) ApplyEvent(eventType string, payload json.RawMessage) { existing, cached := c.beads[b.ID] if !cached || beadChanged(existing, b, false) { c.noteMutationLocked(b.ID) - c.beads[b.ID] = cloneBead(b) - delete(c.dirty, b.ID) - delete(c.deletedSeq, b.ID) + c.absorbFreshLocked(b.ID, b, time.Now(), absorbOpts{ + depsMode: depsKeepCached, + seqMode: seqKeep, + clearDirty: true, + }) mutated = true } if depsMutated := c.updateEventDepsLocked(eventType, b, fields, refreshedFromBacking); depsMutated && !mutated { @@ -248,22 +254,20 @@ func (c *CachingStore) ApplyEvent(eventType string, payload json.RawMessage) { if _, exists := c.beads[b.ID]; !exists { c.updateStatsLocked() } - c.beads[b.ID] = cloneBead(b) + // OC-3: absorb before updateEventDepsLocked (see bead.created). + c.absorbFreshLocked(b.ID, b, time.Now(), absorbOpts{ + depsMode: depsKeepCached, + seqMode: seqKeep, + clearDirty: true, + }) c.updateEventDepsLocked(eventType, b, fields, refreshedFromBacking) - delete(c.dirty, b.ID) - delete(c.deletedSeq, b.ID) mutated = true if c.clearDependentReadyProjectionsLocked(b.ID) { mutated = true } case "bead.deleted": c.noteMutationLocked(b.ID) - delete(c.beads, b.ID) - delete(c.deps, b.ID) - delete(c.dirty, b.ID) - delete(c.beadSeq, b.ID) - delete(c.localBeadAt, b.ID) - c.deletedSeq[b.ID] = c.mutationSeq + c.tombstoneLocked(b.ID, c.mutationSeq) c.updateStatsLocked() mutated = true if c.clearDependentReadyProjectionsLocked(b.ID) { @@ -351,8 +355,7 @@ func (c *CachingStore) ApplyDepEvent(beadID string, deps []Dep) { c.noteMutationLocked(beadID) c.deps[beadID] = cloneDeps(deps) c.clearReadyProjectionLocked(beadID) - delete(c.dirty, beadID) - delete(c.deletedSeq, beadID) + c.clearStalenessMarksLocked(beadID) c.markFreshLocked(time.Now()) c.updateStatsLocked() } diff --git a/internal/beads/caching_store_graph_apply.go b/internal/beads/caching_store_graph_apply.go index 828e38170f..14ed899c19 100644 --- a/internal/beads/caching_store_graph_apply.go +++ b/internal/beads/caching_store_graph_apply.go @@ -89,17 +89,19 @@ func (c *CachingStore) refreshGraphAppliedBeads(result *GraphApplyResult) { for _, item := range refreshed { if item.found { fresh := cloneBead(item.bead) - c.beads[item.id] = fresh - c.deps[item.id] = cloneDeps(item.bead.Dependencies) - delete(c.dirty, item.id) - delete(c.deletedSeq, item.id) + c.absorbFreshLocked(item.id, item.bead, now, absorbOpts{ + depsMode: depsExplicit, + deps: item.bead.Dependencies, + seqMode: seqKeep, + clearDirty: true, + }) notifications = append(notifications, cacheNotification{ eventType: "bead.created", bead: fresh, }) continue } - c.dirty[item.id] = struct{}{} + c.markDirtyLocked(item.id) } c.markFreshLocked(now) c.updateStatsLocked() diff --git a/internal/beads/caching_store_primitives_test.go b/internal/beads/caching_store_primitives_test.go new file mode 100644 index 0000000000..e05c893370 --- /dev/null +++ b/internal/beads/caching_store_primitives_test.go @@ -0,0 +1,469 @@ +package beads + +import ( + "context" + "encoding/json" + "testing" + "time" +) + +// newPrimitiveTestStore builds a CachingStore with pre-seeded six-map state so +// the primitive unit tests can assert exact post-state across ALL six maps — +// the contract is as much about which maps do NOT move as which do. +func newPrimitiveTestStore() *CachingStore { + return &CachingStore{ + beads: make(map[string]Bead), + deps: make(map[string][]Dep), + dirty: make(map[string]struct{}), + beadSeq: make(map[string]uint64), + localBeadAt: make(map[string]time.Time), + deletedSeq: make(map[string]uint64), + } +} + +func TestEvictLockedRemovesAllSixMaps(t *testing.T) { + c := newPrimitiveTestStore() + id := "gc-1" + c.beads[id] = Bead{ID: id} + c.deps[id] = []Dep{{IssueID: id, DependsOnID: "gc-2", Type: "blocks"}} + c.dirty[id] = struct{}{} + c.beadSeq[id] = 7 + c.localBeadAt[id] = time.Now() + c.deletedSeq[id] = 3 + // Unrelated row must survive. + c.beads["gc-9"] = Bead{ID: "gc-9"} + c.beadSeq["gc-9"] = 4 + + c.evictLocked(id) + + assertAbsent(t, c, id) + if _, ok := c.beads["gc-9"]; !ok { + t.Fatal("evictLocked removed an unrelated row") + } + if c.beadSeq["gc-9"] != 4 { + t.Fatal("evictLocked disturbed an unrelated beadSeq") + } +} + +func TestTombstoneLockedEvictsThenFences(t *testing.T) { + c := newPrimitiveTestStore() + id := "gc-1" + c.beads[id] = Bead{ID: id} + c.deps[id] = []Dep{{IssueID: id}} + c.dirty[id] = struct{}{} + c.beadSeq[id] = 7 + c.localBeadAt[id] = time.Now() + c.deletedSeq[id] = 2 + + c.tombstoneLocked(id, 42) + + if _, ok := c.beads[id]; ok { + t.Fatal("tombstone left a live row") + } + if _, ok := c.deps[id]; ok { + t.Fatal("tombstone left deps") + } + if _, ok := c.dirty[id]; ok { + t.Fatal("tombstone left dirty") + } + if _, ok := c.beadSeq[id]; ok { + t.Fatal("tombstone left beadSeq") + } + if _, ok := c.localBeadAt[id]; ok { + t.Fatal("tombstone left localBeadAt") + } + if c.deletedSeq[id] != 42 { + t.Fatalf("tombstone fence = %d, want 42", c.deletedSeq[id]) + } +} + +func TestAbsorbFreshLockedDepsModes(t *testing.T) { + now := time.Now() + blocking := Bead{ID: "gc-1", Needs: []string{"gc-2"}} + bare := Bead{ID: "gc-1"} + cachedDeps := []Dep{{IssueID: "gc-1", DependsOnID: "gc-9", Type: "blocks"}} + explicit := []Dep{{IssueID: "gc-1", DependsOnID: "gc-3", Type: "blocks"}} + + cases := []struct { + name string + bead Bead + opts absorbOpts + wantDeps func(t *testing.T, deps []Dep, present bool) + }{ + { + name: "explicit", + bead: bare, + opts: absorbOpts{depsMode: depsExplicit, deps: explicit, seqMode: seqKeep, clearDirty: true}, + wantDeps: func(t *testing.T, deps []Dep, present bool) { + if !present || len(deps) != 1 || deps[0].DependsOnID != "gc-3" { + t.Fatalf("depsExplicit: got %v present=%v", deps, present) + } + }, + }, + { + name: "fromFields carrying", + bead: blocking, + opts: absorbOpts{depsMode: depsFromFields, seqMode: seqKeep, clearDirty: true}, + wantDeps: func(t *testing.T, deps []Dep, present bool) { + if !present || len(deps) != 1 || deps[0].DependsOnID != "gc-2" { + t.Fatalf("depsFromFields carrying: got %v present=%v", deps, present) + } + }, + }, + { + name: "fromFields bare writes nil unconditionally", + bead: bare, + opts: absorbOpts{depsMode: depsFromFields, seqMode: seqKeep, clearDirty: true}, + wantDeps: func(t *testing.T, deps []Dep, present bool) { + if !present || deps != nil { + t.Fatalf("depsFromFields bare: want present nil, got %v present=%v", deps, present) + } + }, + }, + { + name: "fromFieldsIfCarried skips bare", + bead: bare, + opts: absorbOpts{depsMode: depsFromFieldsIfCarried, seqMode: seqKeep, clearDirty: true}, + wantDeps: func(t *testing.T, deps []Dep, present bool) { + if !present || len(deps) != 1 || deps[0].DependsOnID != "gc-9" { + t.Fatalf("depsFromFieldsIfCarried bare should keep cached: got %v present=%v", deps, present) + } + }, + }, + { + name: "keepCached", + bead: blocking, + opts: absorbOpts{depsMode: depsKeepCached, seqMode: seqKeep, clearDirty: true}, + wantDeps: func(t *testing.T, deps []Dep, present bool) { + if !present || len(deps) != 1 || deps[0].DependsOnID != "gc-9" { + t.Fatalf("depsKeepCached: got %v present=%v", deps, present) + } + }, + }, + { + name: "drop", + bead: blocking, + opts: absorbOpts{depsMode: depsDrop, seqMode: seqKeep, clearDirty: true}, + wantDeps: func(t *testing.T, deps []Dep, present bool) { + if present { + t.Fatalf("depsDrop: deps should be absent, got %v", deps) + } + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := newPrimitiveTestStore() + c.deps["gc-1"] = cachedDeps + c.dirty["gc-1"] = struct{}{} + c.deletedSeq["gc-1"] = 5 + c.absorbFreshLocked("gc-1", tc.bead, now, tc.opts) + + if _, ok := c.beads["gc-1"]; !ok { + t.Fatal("absorb did not install the row") + } + if _, ok := c.deletedSeq["gc-1"]; ok { + t.Fatal("absorb must always clear the tombstone") + } + if _, ok := c.dirty["gc-1"]; ok { + t.Fatal("clearDirty:true must clear the dirty mark") + } + deps, present := c.deps["gc-1"] + tc.wantDeps(t, deps, present) + }) + } +} + +func TestAbsorbFreshLockedSeqModes(t *testing.T) { + now := time.Now() + + t.Run("seqKeep touches neither fence", func(t *testing.T) { + c := newPrimitiveTestStore() + c.beadSeq["gc-1"] = 9 + c.localBeadAt["gc-1"] = now + c.absorbFreshLocked("gc-1", Bead{ID: "gc-1"}, now, absorbOpts{depsMode: depsKeepCached, seqMode: seqKeep, clearDirty: true}) + if c.beadSeq["gc-1"] != 9 { + t.Fatal("seqKeep cleared beadSeq") + } + if _, ok := c.localBeadAt["gc-1"]; !ok { + t.Fatal("seqKeep cleared localBeadAt") + } + }) + + t.Run("seqClearGuarded keeps recent local", func(t *testing.T) { + c := newPrimitiveTestStore() + c.beadSeq["gc-1"] = 9 + c.localBeadAt["gc-1"] = now // recent -> keep + c.absorbFreshLocked("gc-1", Bead{ID: "gc-1"}, now, absorbOpts{depsMode: depsKeepCached, seqMode: seqClearGuarded, clearDirty: true}) + if c.beadSeq["gc-1"] != 9 { + t.Fatal("seqClearGuarded cleared a recent-local fence") + } + if _, ok := c.localBeadAt["gc-1"]; !ok { + t.Fatal("seqClearGuarded cleared a recent-local localBeadAt") + } + }) + + t.Run("seqClearGuarded clears stale local", func(t *testing.T) { + c := newPrimitiveTestStore() + c.beadSeq["gc-1"] = 9 + c.localBeadAt["gc-1"] = now.Add(-10 * time.Second) // stale -> clear + c.absorbFreshLocked("gc-1", Bead{ID: "gc-1"}, now, absorbOpts{depsMode: depsKeepCached, seqMode: seqClearGuarded, clearDirty: true}) + if _, ok := c.beadSeq["gc-1"]; ok { + t.Fatal("seqClearGuarded left a stale beadSeq") + } + if _, ok := c.localBeadAt["gc-1"]; ok { + t.Fatal("seqClearGuarded left a stale localBeadAt") + } + }) + + t.Run("seqClearBeadSeqOnly clears beadSeq keeps localBeadAt", func(t *testing.T) { + c := newPrimitiveTestStore() + c.beadSeq["gc-1"] = 9 + c.localBeadAt["gc-1"] = now + c.absorbFreshLocked("gc-1", Bead{ID: "gc-1"}, now, absorbOpts{depsMode: depsKeepCached, seqMode: seqClearBeadSeqOnly, clearDirty: true}) + if _, ok := c.beadSeq["gc-1"]; ok { + t.Fatal("seqClearBeadSeqOnly left beadSeq") + } + if _, ok := c.localBeadAt["gc-1"]; !ok { + t.Fatal("seqClearBeadSeqOnly cleared localBeadAt") + } + }) +} + +func TestAbsorbFreshLockedClearDirtyFalseKeepsMark(t *testing.T) { + now := time.Now() + c := newPrimitiveTestStore() + c.dirty["gc-1"] = struct{}{} + c.absorbFreshLocked("gc-1", Bead{ID: "gc-1"}, now, absorbOpts{depsMode: depsKeepCached, seqMode: seqKeep, clearDirty: false}) + if _, ok := c.dirty["gc-1"]; !ok { + t.Fatal("clearDirty:false must leave the dirty mark in place") + } + if _, ok := c.deletedSeq["gc-1"]; ok { + t.Fatal("absorb must clear the tombstone even when clearDirty is false") + } +} + +// T7 — seqKeep divergence guard. +// +// Event paths run noteMutationLocked (beadSeq only, NO localBeadAt) immediately +// before absorbing. seqKeep is load-bearing there: a seqClearGuarded at an +// event site would find no recent localBeadAt and DELETE the beadSeq fence the +// event just installed, so the next in-flight snapshot (an older-startSeq List +// or reconcile) would clobber the event's row — the #2210/#2987 stale-read +// class. These tests pin that the real event sites keep the fence and that a +// stale snapshot is rejected by it. + +// TestApplyEventSitesPreserveBeadSeqFence exercises each real event absorb site +// (EV1 created, EV2 updated, EV3 closed) and asserts the beadSeq fence set by +// the event's noteMutationLocked survives the absorb (seqKeep). +func TestApplyEventSitesPreserveBeadSeqFence(t *testing.T) { + t.Parallel() + + t.Run("bead.created", func(t *testing.T) { + t.Parallel() + backing := NewMemStore() + cache := NewCachingStoreForTest(backing, nil) + if err := cache.Prime(context.Background()); err != nil { + t.Fatalf("Prime: %v", err) + } + cache.ApplyEvent("bead.created", json.RawMessage(`{"id":"gc-created","status":"open","issue_type":"task"}`)) + assertBeadSeqPresent(t, cache, "gc-created") + }) + + t.Run("bead.updated", func(t *testing.T) { + t.Parallel() + backing := NewMemStore() + bead, err := backing.Create(Bead{Title: "before", Status: "open", Type: "task"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + cache := NewCachingStoreForTest(backing, nil) + if err := cache.Prime(context.Background()); err != nil { + t.Fatalf("Prime: %v", err) + } + after := "after" + if err := backing.Update(bead.ID, UpdateOpts{Title: &after}); err != nil { + t.Fatalf("Update backing: %v", err) + } + cache.ApplyEvent("bead.updated", json.RawMessage(`{"id":"`+bead.ID+`","title":"after"}`)) + assertBeadSeqPresent(t, cache, bead.ID) + }) + + t.Run("bead.closed", func(t *testing.T) { + t.Parallel() + backing := NewMemStore() + bead, err := backing.Create(Bead{Title: "open", Status: "open", Type: "task"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + cache := NewCachingStoreForTest(backing, nil) + if err := cache.Prime(context.Background()); err != nil { + t.Fatalf("Prime: %v", err) + } + if err := backing.Close(bead.ID); err != nil { + t.Fatalf("Close backing: %v", err) + } + cache.ApplyEvent("bead.closed", json.RawMessage(`{"id":"`+bead.ID+`","status":"closed"}`)) + assertBeadSeqPresent(t, cache, bead.ID) + }) +} + +// TestStaleSnapshotDoesNotClobberFencedEventRow drives the real List-refresh +// merge (refreshCachedBeads) with a startSeq captured BEFORE an event and a +// stale snapshot of the pre-event row. The beadSeq fence the event installed +// (> startSeq) must reject the stale row: the cached (event) row survives and +// the stale value is never absorbed. +func TestStaleSnapshotDoesNotClobberFencedEventRow(t *testing.T) { + t.Parallel() + + backing := NewMemStore() + bead, err := backing.Create(Bead{Title: "before-event", Status: "open", Type: "task"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + cache := NewCachingStoreForTest(backing, nil) + if err := cache.Prime(context.Background()); err != nil { + t.Fatalf("Prime: %v", err) + } + + cache.mu.RLock() + staleStartSeq := cache.mutationSeq + cache.mu.RUnlock() + + // The event advances the row past staleStartSeq and installs the beadSeq + // fence via noteMutationLocked; the backing is updated first so the event + // carries no field conflict against the cache. + after := "after-event" + if err := backing.Update(bead.ID, UpdateOpts{Title: &after}); err != nil { + t.Fatalf("Update backing: %v", err) + } + cache.ApplyEvent("bead.updated", json.RawMessage(`{"id":"`+bead.ID+`","title":"after-event"}`)) + + cache.mu.RLock() + fenced := cache.beadSeq[bead.ID] > staleStartSeq + cache.mu.RUnlock() + if !fenced { + t.Fatalf("precondition: event did not install a beadSeq fence past startSeq") + } + + staleItem := Bead{ID: bead.ID, Title: "before-event", Status: "open", Type: "task"} + refreshed := cache.refreshCachedBeads(ListQuery{Status: "open"}, staleStartSeq, []Bead{staleItem}) + + cache.mu.RLock() + cachedTitle := cache.beads[bead.ID].Title + cache.mu.RUnlock() + if cachedTitle != "after-event" { + t.Fatalf("stale snapshot clobbered the fenced row: cached title = %q, want %q", cachedTitle, "after-event") + } + for _, b := range refreshed { + if b.ID == bead.ID && b.Title == "before-event" { + t.Fatal("stale snapshot value was served past the beadSeq fence") + } + } +} + +// TestAbsorbSeqModeDivergenceAtEventPreState meta-verifies the seqKeep vs +// seqClearGuarded divergence against the exact pre-state an event site leaves: +// beadSeq set, localBeadAt absent (noteMutationLocked stamps only beadSeq). +// seqKeep preserves the fence; seqClearGuarded — the wrong choice at an event +// site — deletes it, which is precisely the silent stale-read regression T7 +// exists to catch. +func TestAbsorbSeqModeDivergenceAtEventPreState(t *testing.T) { + t.Parallel() + now := time.Now() + + seqKeepStore := newPrimitiveTestStore() + seqKeepStore.beadSeq["gc-1"] = 9 // noteMutationLocked-style: beadSeq only + seqKeepStore.absorbFreshLocked("gc-1", Bead{ID: "gc-1"}, now, absorbOpts{depsMode: depsKeepCached, seqMode: seqKeep, clearDirty: true}) + if seqKeepStore.beadSeq["gc-1"] != 9 { + t.Fatal("seqKeep must preserve the event-installed beadSeq fence") + } + + seqClearStore := newPrimitiveTestStore() + seqClearStore.beadSeq["gc-1"] = 9 // same event pre-state: no localBeadAt + seqClearStore.absorbFreshLocked("gc-1", Bead{ID: "gc-1"}, now, absorbOpts{depsMode: depsKeepCached, seqMode: seqClearGuarded, clearDirty: true}) + if _, ok := seqClearStore.beadSeq["gc-1"]; ok { + t.Fatal("expected seqClearGuarded to DELETE the fence at the event pre-state (this is why event sites MUST use seqKeep)") + } +} + +// T6 — ApplyEvent OC-3 ordering: absorb installs the row BEFORE the +// deps-overlay (updateEventDepsLocked → setEventDepsLocked → +// clearReadyProjectionLocked), so the overlay observes the newly absorbed row. +// If the order were inverted, clearReadyProjectionLocked would no-op on the +// still-absent row and the projected IsBlocked would survive. +func TestApplyEventAbsorbsBeforeDepsOverlay_OC3(t *testing.T) { + t.Parallel() + + backing := NewMemStore() + blocker, err := backing.Create(Bead{Title: "blocker", Status: "open", Type: "task"}) + if err != nil { + t.Fatalf("Create blocker: %v", err) + } + cache := NewCachingStoreForTest(backing, nil) + if err := cache.Prime(context.Background()); err != nil { + t.Fatalf("Prime: %v", err) + } + + // A created event that carries a projected IsBlocked AND dependency fields. + // EV1 absorbs the row (IsBlocked=true) then runs the deps overlay, which + // clears the projection now that authoritative deps are known. + payload, err := json.Marshal(map[string]any{ + "id": "gc-blocked", + "status": "open", + "issue_type": "task", + "is_blocked": true, + "needs": []string{blocker.ID}, + }) + if err != nil { + t.Fatalf("marshal created event: %v", err) + } + cache.ApplyEvent("bead.created", payload) + + got, err := cache.Get("gc-blocked") + if err != nil { + t.Fatalf("Get after created event: %v", err) + } + if got.IsBlocked != nil { + t.Fatalf("OC-3 violated: projected IsBlocked = %v, want nil (overlay must clear the newly absorbed row)", *got.IsBlocked) + } + + cache.mu.RLock() + _, hasDeps := cache.deps["gc-blocked"] + cache.mu.RUnlock() + if !hasDeps { + t.Fatal("expected the deps overlay to install authoritative deps for the absorbed row") + } +} + +func assertBeadSeqPresent(t *testing.T, c *CachingStore, id string) { + t.Helper() + c.mu.RLock() + defer c.mu.RUnlock() + if _, ok := c.beadSeq[id]; !ok { + t.Fatalf("event site did not preserve the beadSeq fence for %s (seqKeep regression)", id) + } +} + +func assertAbsent(t *testing.T, c *CachingStore, id string) { + t.Helper() + if _, ok := c.beads[id]; ok { + t.Fatalf("%s still in beads", id) + } + if _, ok := c.deps[id]; ok { + t.Fatalf("%s still in deps", id) + } + if _, ok := c.dirty[id]; ok { + t.Fatalf("%s still in dirty", id) + } + if _, ok := c.beadSeq[id]; ok { + t.Fatalf("%s still in beadSeq", id) + } + if _, ok := c.localBeadAt[id]; ok { + t.Fatalf("%s still in localBeadAt", id) + } + if _, ok := c.deletedSeq[id]; ok { + t.Fatalf("%s still in deletedSeq", id) + } +} diff --git a/internal/beads/caching_store_reads.go b/internal/beads/caching_store_reads.go index c250343a93..32bb69630d 100644 --- a/internal/beads/caching_store_reads.go +++ b/internal/beads/caching_store_reads.go @@ -230,16 +230,11 @@ func (c *CachingStore) refreshCachedBeads(query ListQuery, startSeq uint64, item continue } } - c.beads[item.ID] = cloneBead(item) - if beadCarriesDependencyFields(item) { - c.deps[item.ID] = depsFromBeadFields(item) - } - delete(c.dirty, item.ID) - delete(c.deletedSeq, item.ID) - if !recentLocalMutation(c.localBeadAt[item.ID], now) { - delete(c.beadSeq, item.ID) - delete(c.localBeadAt, item.ID) - } + c.absorbFreshLocked(item.ID, item, now, absorbOpts{ + depsMode: depsFromFieldsIfCarried, + seqMode: seqClearGuarded, + clearDirty: true, + }) if query.Matches(item) { refreshed = append(refreshed, cloneBead(item)) } @@ -251,16 +246,11 @@ func (c *CachingStore) refreshCachedBeads(query ListQuery, startSeq uint64, item if _, keep := c.recentLocalBeadConflictLocked(id, bead, now, false); keep { continue } - c.beads[id] = bead - if beadCarriesDependencyFields(bead) { - c.deps[id] = depsFromBeadFields(bead) - } - delete(c.dirty, id) - delete(c.deletedSeq, id) - if !recentLocalMutation(c.localBeadAt[id], now) { - delete(c.beadSeq, id) - delete(c.localBeadAt, id) - } + c.absorbFreshLocked(id, bead, now, absorbOpts{ + depsMode: depsFromFieldsIfCarried, + seqMode: seqClearGuarded, + clearDirty: true, + }) } for id := range removedParents { if c.deletedSeq[id] > startSeq || c.beadSeq[id] > startSeq { @@ -269,12 +259,7 @@ func (c *CachingStore) refreshCachedBeads(query ListQuery, startSeq uint64, item if current, ok := c.beads[id]; ok && current.Status != "closed" && recentLocalMutation(c.localBeadAt[id], now) { continue } - delete(c.beads, id) - delete(c.deps, id) - delete(c.dirty, id) - delete(c.deletedSeq, id) - delete(c.beadSeq, id) - delete(c.localBeadAt, id) + c.evictLocked(id) } for id, bead := range refreshedLiveMissing { if c.deletedSeq[id] > startSeq || c.beadSeq[id] > startSeq { @@ -283,16 +268,11 @@ func (c *CachingStore) refreshCachedBeads(query ListQuery, startSeq uint64, item if _, keep := c.recentLocalBeadConflictLocked(id, bead, now, false); keep { continue } - c.beads[id] = bead - if beadCarriesDependencyFields(bead) { - c.deps[id] = depsFromBeadFields(bead) - } - delete(c.dirty, id) - delete(c.deletedSeq, id) - if !recentLocalMutation(c.localBeadAt[id], now) { - delete(c.beadSeq, id) - delete(c.localBeadAt, id) - } + c.absorbFreshLocked(id, bead, now, absorbOpts{ + depsMode: depsFromFieldsIfCarried, + seqMode: seqClearGuarded, + clearDirty: true, + }) } for id := range removedLiveMissing { if c.deletedSeq[id] > startSeq || c.beadSeq[id] > startSeq { @@ -301,12 +281,7 @@ func (c *CachingStore) refreshCachedBeads(query ListQuery, startSeq uint64, item if current, ok := c.beads[id]; ok && current.Status != "closed" && recentLocalMutation(c.localBeadAt[id], now) { continue } - delete(c.beads, id) - delete(c.deps, id) - delete(c.dirty, id) - delete(c.deletedSeq, id) - delete(c.beadSeq, id) - delete(c.localBeadAt, id) + c.evictLocked(id) } c.markFreshLocked(time.Now()) c.updateStatsLocked() @@ -424,11 +399,11 @@ func (c *CachingStore) Get(id string) (Bead, error) { c.mu.Unlock() return Bead{}, ErrNotFound } - c.beads[id] = cloneBead(fresh) - c.deps[id] = depsFromBeadFields(fresh) - delete(c.dirty, id) - delete(c.deletedSeq, id) - delete(c.beadSeq, id) + c.absorbFreshLocked(id, fresh, time.Now(), absorbOpts{ + depsMode: depsFromFields, + seqMode: seqClearBeadSeqOnly, + clearDirty: true, + }) c.markFreshLocked(time.Now()) c.updateStatsLocked() c.mu.Unlock() diff --git a/internal/beads/caching_store_reconcile.go b/internal/beads/caching_store_reconcile.go index 4768bc0bb3..aa8ab4809c 100644 --- a/internal/beads/caching_store_reconcile.go +++ b/internal/beads/caching_store_reconcile.go @@ -387,14 +387,12 @@ func (c *CachingStore) runReconciliation() { }) } - c.beads[id] = cloneBead(freshBead) - c.deps[id] = cloneDeps(freshDeps) - delete(c.dirty, id) - delete(c.deletedSeq, id) - if !recentLocalMutation(c.localBeadAt[id], now) { - delete(c.beadSeq, id) - delete(c.localBeadAt, id) - } + c.absorbFreshLocked(id, freshBead, now, absorbOpts{ + depsMode: depsExplicit, + deps: freshDeps, + seqMode: seqClearGuarded, + clearDirty: true, + }) } for id, old := range c.beads { @@ -419,12 +417,7 @@ func (c *CachingStore) runReconciliation() { bead: closed, }) } - delete(c.beads, id) - delete(c.deps, id) - delete(c.dirty, id) - delete(c.deletedSeq, id) - delete(c.beadSeq, id) - delete(c.localBeadAt, id) + c.evictLocked(id) } c.syncFailures = 0 diff --git a/internal/beads/caching_store_writes.go b/internal/beads/caching_store_writes.go index e6cebcd103..5118271ef9 100644 --- a/internal/beads/caching_store_writes.go +++ b/internal/beads/caching_store_writes.go @@ -39,10 +39,11 @@ func (c *CachingStore) createWith(create func() (Bead, error)) (Bead, error) { c.mu.Lock() c.noteLocalMutationLocked(created.ID) - c.beads[created.ID] = cloneBead(created) - c.deps[created.ID] = depsFromBeadFields(created) - delete(c.dirty, created.ID) - delete(c.deletedSeq, created.ID) + c.absorbFreshLocked(created.ID, created, time.Now(), absorbOpts{ + depsMode: depsFromFields, + seqMode: seqKeep, + clearDirty: true, + }) c.markFreshLocked(time.Now()) c.updateStatsLocked() c.mu.Unlock() @@ -79,12 +80,7 @@ func (c *CachingStore) Update(id string, opts UpdateOpts) error { closed.Status = "closed" notifyClosed = true } - delete(c.beads, id) - delete(c.deps, id) - delete(c.dirty, id) - delete(c.beadSeq, id) - delete(c.localBeadAt, id) - c.deletedSeq[id] = seq + c.tombstoneLocked(id, seq) c.clearDependentReadyProjectionsLocked(id) c.markFreshLocked(time.Now()) c.updateStatsLocked() @@ -96,20 +92,22 @@ func (c *CachingStore) Update(id string, opts UpdateOpts) error { } if current, ok := c.beads[id]; ok { fresh = applyUpdateOptsToBead(current, opts) - c.beads[id] = cloneBead(fresh) - c.deps[id] = depsFromBeadFields(fresh) + c.absorbFreshLocked(id, fresh, time.Now(), absorbOpts{ + depsMode: depsFromFields, + seqMode: seqKeep, + clearDirty: false, + }) if opts.Status != nil { c.clearDependentReadyProjectionsLocked(id) } - c.dirty[id] = struct{}{} - delete(c.deletedSeq, id) + c.markDirtyLocked(id) c.updateStatsLocked() c.mu.Unlock() c.recordProblem("refresh bead after update", fmt.Errorf("%s: %w", id, err)) c.notifyChange("bead.updated", fresh) return nil } - c.dirty[id] = struct{}{} + c.markDirtyLocked(id) c.mu.Unlock() c.recordProblem("refresh bead after update", fmt.Errorf("%s: %w", id, err)) return nil @@ -118,13 +116,14 @@ func (c *CachingStore) Update(id string, opts UpdateOpts) error { c.mu.Lock() c.noteLocalMutationLocked(id) - c.beads[id] = cloneBead(fresh) - c.deps[id] = depsFromBeadFields(fresh) + c.absorbFreshLocked(id, fresh, time.Now(), absorbOpts{ + depsMode: depsFromFields, + seqMode: seqKeep, + clearDirty: true, + }) if opts.Status != nil { c.clearDependentReadyProjectionsLocked(id) } - delete(c.dirty, id) - delete(c.deletedSeq, id) c.markFreshLocked(time.Now()) c.updateStatsLocked() c.mu.Unlock() @@ -151,23 +150,27 @@ func (c *CachingStore) ReleaseIfCurrent(id, expectedAssignee string) (bool, erro c.mu.Lock() c.noteLocalMutationLocked(id) if refreshed { - c.beads[id] = cloneBead(fresh) - c.deps[id] = depsFromBeadFields(fresh) - delete(c.dirty, id) - delete(c.deletedSeq, id) + c.absorbFreshLocked(id, fresh, time.Now(), absorbOpts{ + depsMode: depsFromFields, + seqMode: seqKeep, + clearDirty: true, + }) updated = cloneBead(fresh) notify = true } else if b, ok := c.beads[id]; ok { b.Status = "open" b.Assignee = "" b.UpdatedAt = time.Now() - c.beads[id] = b - c.dirty[id] = struct{}{} - delete(c.deletedSeq, id) + c.absorbFreshLocked(id, b, time.Now(), absorbOpts{ + depsMode: depsKeepCached, + seqMode: seqKeep, + clearDirty: false, + }) + c.markDirtyLocked(id) updated = cloneBead(b) notify = true } else { - c.dirty[id] = struct{}{} + c.markDirtyLocked(id) } c.clearDependentReadyProjectionsLocked(id) c.markFreshLocked(time.Now()) @@ -206,15 +209,19 @@ func (c *CachingStore) Close(id string) error { c.noteLocalMutationLocked(id) if b, ok := c.beads[id]; ok { b.Status = "closed" - c.beads[id] = b - delete(c.dirty, id) - delete(c.deletedSeq, id) + c.absorbFreshLocked(id, b, time.Now(), absorbOpts{ + depsMode: depsKeepCached, + seqMode: seqKeep, + clearDirty: true, + }) closed = cloneBead(b) found = true } else if found { - c.beads[id] = cloneBead(closed) - delete(c.dirty, id) - delete(c.deletedSeq, id) + c.absorbFreshLocked(id, closed, time.Now(), absorbOpts{ + depsMode: depsKeepCached, + seqMode: seqKeep, + clearDirty: true, + }) } dependentProjectionCleared := c.clearDependentReadyProjectionsLocked(id) if found || dependentProjectionCleared { @@ -249,15 +256,19 @@ func (c *CachingStore) Reopen(id string) error { c.noteLocalMutationLocked(id) if b, ok := c.beads[id]; ok { b.Status = "open" - c.beads[id] = b - delete(c.dirty, id) - delete(c.deletedSeq, id) + c.absorbFreshLocked(id, b, time.Now(), absorbOpts{ + depsMode: depsKeepCached, + seqMode: seqKeep, + clearDirty: true, + }) reopened = cloneBead(b) found = true } else if found { - c.beads[id] = cloneBead(reopened) - delete(c.dirty, id) - delete(c.deletedSeq, id) + c.absorbFreshLocked(id, reopened, time.Now(), absorbOpts{ + depsMode: depsKeepCached, + seqMode: seqKeep, + clearDirty: true, + }) } dependentProjectionCleared := c.clearDependentReadyProjectionsLocked(id) if found || dependentProjectionCleared { @@ -303,15 +314,16 @@ func (c *CachingStore) CloseAll(ids []string, metadata map[string]string) (int, c.recordProblemLocked("close-all refresh", refreshErr) } for id := range refreshFailed { - c.dirty[id] = struct{}{} + c.markDirtyLocked(id) } for _, item := range refreshed { previous, hadPrevious := c.beads[item.id] - c.beads[item.id] = cloneBead(item.bead) - delete(c.dirty, item.id) - delete(c.deletedSeq, item.id) + opts := absorbOpts{depsMode: depsKeepCached, seqMode: seqKeep, clearDirty: true} + if item.bead.Status == "closed" { + opts.depsMode = depsDrop + } + c.absorbFreshLocked(item.id, item.bead, time.Now(), opts) if item.bead.Status == "closed" { - delete(c.deps, item.id) c.clearDependentReadyProjectionsLocked(item.id) } if hadPrevious && previous.Status != "closed" && item.bead.Status == "closed" { @@ -352,10 +364,11 @@ func (c *CachingStore) SetMetadata(id, key, value string) error { c.mu.Lock() c.noteLocalMutationLocked(id) if refreshed { - c.beads[id] = cloneBead(fresh) - c.deps[id] = depsFromBeadFields(fresh) - delete(c.dirty, id) - delete(c.deletedSeq, id) + c.absorbFreshLocked(id, fresh, time.Now(), absorbOpts{ + depsMode: depsFromFields, + seqMode: seqKeep, + clearDirty: true, + }) updated = cloneBead(fresh) notify = true } else if b, ok := c.beads[id]; ok { @@ -363,13 +376,15 @@ func (c *CachingStore) SetMetadata(id, key, value string) error { b.Metadata = make(map[string]string) } b.Metadata[key] = value - c.beads[id] = b - delete(c.dirty, id) - delete(c.deletedSeq, id) + c.absorbFreshLocked(id, b, time.Now(), absorbOpts{ + depsMode: depsKeepCached, + seqMode: seqKeep, + clearDirty: true, + }) updated = cloneBead(b) notify = true } else { - c.dirty[id] = struct{}{} + c.markDirtyLocked(id) } c.markFreshLocked(time.Now()) c.updateStatsLocked() @@ -404,10 +419,11 @@ func (c *CachingStore) SetMetadataBatch(id string, kvs map[string]string) error c.mu.Lock() c.noteLocalMutationLocked(id) if refreshed { - c.beads[id] = cloneBead(fresh) - c.deps[id] = depsFromBeadFields(fresh) - delete(c.dirty, id) - delete(c.deletedSeq, id) + c.absorbFreshLocked(id, fresh, time.Now(), absorbOpts{ + depsMode: depsFromFields, + seqMode: seqKeep, + clearDirty: true, + }) updated = cloneBead(fresh) notify = true } else if b, ok := c.beads[id]; ok { @@ -417,13 +433,15 @@ func (c *CachingStore) SetMetadataBatch(id string, kvs map[string]string) error for k, v := range kvs { b.Metadata[k] = v } - c.beads[id] = b - delete(c.dirty, id) - delete(c.deletedSeq, id) + c.absorbFreshLocked(id, b, time.Now(), absorbOpts{ + depsMode: depsKeepCached, + seqMode: seqKeep, + clearDirty: true, + }) updated = cloneBead(b) notify = true } else { - c.dirty[id] = struct{}{} + c.markDirtyLocked(id) } c.markFreshLocked(time.Now()) c.updateStatsLocked() @@ -587,10 +605,11 @@ func (c *CachingStore) refreshTxTouchedBeads(ids []string, closed map[string]str if hadPrevious && previous.Status != fresh.Status { statusChanged = true } - c.beads[item.id] = fresh - c.deps[item.id] = depsFromBeadFields(fresh) - delete(c.dirty, item.id) - delete(c.deletedSeq, item.id) + c.absorbFreshLocked(item.id, fresh, now, absorbOpts{ + depsMode: depsFromFields, + seqMode: seqKeep, + clearDirty: true, + }) if statusChanged { c.clearDependentReadyProjectionsLocked(item.id) } @@ -609,9 +628,11 @@ func (c *CachingStore) refreshTxTouchedBeads(ids []string, closed map[string]str if item.closed { if b, ok := c.beads[item.id]; ok { b.Status = "closed" - c.beads[item.id] = b - delete(c.dirty, item.id) - delete(c.deletedSeq, item.id) + c.absorbFreshLocked(item.id, b, now, absorbOpts{ + depsMode: depsKeepCached, + seqMode: seqKeep, + clearDirty: true, + }) c.clearDependentReadyProjectionsLocked(item.id) notifications = append(notifications, cacheNotification{ eventType: "bead.closed", @@ -621,7 +642,7 @@ func (c *CachingStore) refreshTxTouchedBeads(ids []string, closed map[string]str continue } if item.err != nil { - c.dirty[item.id] = struct{}{} + c.markDirtyLocked(item.id) } } c.markFreshLocked(now) @@ -794,11 +815,13 @@ func (c *CachingStore) DepAdd(issueID, dependsOnID, depType string) error { c.mu.Lock() c.noteLocalMutationLocked(issueID) if refreshed { - c.beads[issueID] = cloneBead(fresh) - c.deps[issueID] = cloneDeps(deps) + c.absorbFreshLocked(issueID, fresh, time.Now(), absorbOpts{ + depsMode: depsExplicit, + deps: deps, + seqMode: seqKeep, + clearDirty: true, + }) c.clearReadyProjectionLocked(issueID) - delete(c.dirty, issueID) - delete(c.deletedSeq, issueID) c.markFreshLocked(time.Now()) c.updateStatsLocked() c.mu.Unlock() @@ -807,8 +830,7 @@ func (c *CachingStore) DepAdd(issueID, dependsOnID, depType string) error { } if !c.depsComplete { if _, known := c.deps[issueID]; !known { - delete(c.dirty, issueID) - delete(c.deletedSeq, issueID) + c.clearStalenessMarksLocked(issueID) c.markFreshLocked(time.Now()) c.updateStatsLocked() c.mu.Unlock() @@ -821,8 +843,7 @@ func (c *CachingStore) DepAdd(issueID, dependsOnID, depType string) error { cachedDeps[i].Type = depType c.deps[issueID] = cachedDeps c.clearReadyProjectionLocked(issueID) - delete(c.dirty, issueID) - delete(c.deletedSeq, issueID) + c.clearStalenessMarksLocked(issueID) c.markFreshLocked(time.Now()) c.updateStatsLocked() c.mu.Unlock() @@ -831,8 +852,7 @@ func (c *CachingStore) DepAdd(issueID, dependsOnID, depType string) error { } c.deps[issueID] = append(cachedDeps, Dep{IssueID: issueID, DependsOnID: dependsOnID, Type: depType}) c.clearReadyProjectionLocked(issueID) - delete(c.dirty, issueID) - delete(c.deletedSeq, issueID) + c.clearStalenessMarksLocked(issueID) c.markFreshLocked(time.Now()) c.updateStatsLocked() c.mu.Unlock() @@ -849,11 +869,13 @@ func (c *CachingStore) DepRemove(issueID, dependsOnID string) error { c.mu.Lock() c.noteLocalMutationLocked(issueID) if refreshed { - c.beads[issueID] = cloneBead(fresh) - c.deps[issueID] = cloneDeps(deps) + c.absorbFreshLocked(issueID, fresh, time.Now(), absorbOpts{ + depsMode: depsExplicit, + deps: deps, + seqMode: seqKeep, + clearDirty: true, + }) c.clearReadyProjectionLocked(issueID) - delete(c.dirty, issueID) - delete(c.deletedSeq, issueID) c.markFreshLocked(time.Now()) c.updateStatsLocked() c.mu.Unlock() @@ -862,8 +884,7 @@ func (c *CachingStore) DepRemove(issueID, dependsOnID string) error { } if !c.depsComplete { if _, known := c.deps[issueID]; !known { - delete(c.dirty, issueID) - delete(c.deletedSeq, issueID) + c.clearStalenessMarksLocked(issueID) c.markFreshLocked(time.Now()) c.updateStatsLocked() c.mu.Unlock() @@ -875,8 +896,7 @@ func (c *CachingStore) DepRemove(issueID, dependsOnID string) error { if d.DependsOnID == dependsOnID { c.deps[issueID] = append(cachedDeps[:i], cachedDeps[i+1:]...) c.clearReadyProjectionLocked(issueID) - delete(c.dirty, issueID) - delete(c.deletedSeq, issueID) + c.clearStalenessMarksLocked(issueID) break } } @@ -895,12 +915,7 @@ func (c *CachingStore) Delete(id string) error { c.mu.Lock() seq := c.noteLocalMutationLocked(id) - delete(c.beads, id) - delete(c.deps, id) - delete(c.dirty, id) - delete(c.beadSeq, id) - delete(c.localBeadAt, id) - c.deletedSeq[id] = seq + c.tombstoneLocked(id, seq) c.clearDependentReadyProjectionsLocked(id) c.markFreshLocked(time.Now()) c.updateStatsLocked() From 6fd8f8f18d7e7516d025e99eda3cfe31c0baf8a1 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Thu, 9 Jul 2026 12:17:25 -0700 Subject: [PATCH 034/225] simplify(S01) phase 2: collapse runReconciliation via reconcileMergeDecision (differential-gated) (#4092) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- internal/beads/caching_store_reconcile.go | 460 ++++++++----- .../caching_store_reconcile_census_test.go | 128 ++++ .../caching_store_reconcile_coverage_test.go | 332 +++++++++ .../caching_store_reconcile_decision_test.go | 165 +++++ ...ching_store_reconcile_differential_test.go | 554 +++++++++++++++ .../caching_store_reconcile_diffutil_test.go | 180 +++++ .../caching_store_reconcile_fixtures_test.go | 462 +++++++++++++ ...caching_store_reconcile_generators_test.go | 637 ++++++++++++++++++ .../caching_store_reconcile_oracle_test.go | 508 ++++++++++++++ .../caching_store_reconcile_probes_test.go | 490 ++++++++++++++ .../caching_store_reconcile_twopass_test.go | 137 ++++ 11 files changed, 3883 insertions(+), 170 deletions(-) create mode 100644 internal/beads/caching_store_reconcile_census_test.go create mode 100644 internal/beads/caching_store_reconcile_coverage_test.go create mode 100644 internal/beads/caching_store_reconcile_decision_test.go create mode 100644 internal/beads/caching_store_reconcile_differential_test.go create mode 100644 internal/beads/caching_store_reconcile_diffutil_test.go create mode 100644 internal/beads/caching_store_reconcile_fixtures_test.go create mode 100644 internal/beads/caching_store_reconcile_generators_test.go create mode 100644 internal/beads/caching_store_reconcile_oracle_test.go create mode 100644 internal/beads/caching_store_reconcile_probes_test.go create mode 100644 internal/beads/caching_store_reconcile_twopass_test.go diff --git a/internal/beads/caching_store_reconcile.go b/internal/beads/caching_store_reconcile.go index aa8ab4809c..292220888c 100644 --- a/internal/beads/caching_store_reconcile.go +++ b/internal/beads/caching_store_reconcile.go @@ -333,6 +333,162 @@ func (c *CachingStore) runReconciliation() { c.mu.Lock() now := time.Now() + res := c.mergeSnapshotLocked(freshByID, confirmedClosed, depMap, useFreshDeps, startSeq, now) + durMs := float64(time.Since(start).Microseconds()) / 1000.0 + c.stats.LastReconcileMs = durMs + c.recordReconcileLatencyLocked(bdLatency) + c.recomputeCadenceLocked() + c.updateStatsLocked() + logLine, emit := c.reconcileSuccessLogLocked(now, time.Since(start), res.adds, res.removes, res.updates) + c.mu.Unlock() + if emit { + log.Print(logLine) + } + c.notifyChanges(res.notifications) +} + +// mergeAction is what the reconcile merge does with one id. +type mergeAction int + +const ( + // mergeAbsorb installs the fresh row via + // absorbFreshLocked{depsExplicit freshDeps, seqClearGuarded, clearDirty:true}. + mergeAbsorb mergeAction = iota + // mergeEvict removes the cached row via evictLocked. + mergeEvict + // mergeSkipFenced leaves everything for id untouched: a tombstone or + // beadSeq fence > startSeq proves local state is newer than the snapshot. + mergeSkipFenced + // mergeSkipRecentLocal leaves everything for id untouched: the recency + // window (5 s) protects an in-flight local write bd may not reflect yet. + mergeSkipRecentLocal + // mergeGCFences drops every orphan fence/deps entry for id (deletedSeq, + // dirty, beadSeq, localBeadAt, deps). Only reachable when id has no row on + // either side. + mergeGCFences +) + +// mergeDecision is the pure per-id verdict of reconcileMergeDecision. Payload +// assembly (confirmedClosed override, cloneBead) and counter bookkeeping stay +// at the seam call site. +type mergeDecision struct { + action mergeAction + // notification is the event type to synthesize: "", "bead.created", + // "bead.updated", or "bead.closed". + notification string + // degradeDepsComplete reports that this skip leaves the cached deps map an + // unfaithful projection of the fresh full scan, so the pass must fold + // nextDepsComplete = false and dep readers fall back to the backing. Two + // shapes trip it: a coverage hole (cached row with no deps entry), and a + // recency-keep that retains cached deps which diverge from the fresh + // snapshot's deps (the row's body is kept as local truth, but its deps can + // no longer be claimed complete). The first shape matches the two Branch-A + // skip-arm degradations; the second closes the D4 contract gap where a + // recency-keep could serve stale cached deps under depsComplete=true. + degradeDepsComplete bool +} + +// mergeRowInput is the complete per-id state the decision depends on. +// Everything is a value; zero values are the documented "absent" sentinels +// (mutationSeq starts at 1 — noteMutationLocked pre-increments — so seq 0 +// means "no entry"; time.Time zero means "no recency stamp"). +type mergeRowInput struct { + freshExists bool // id present in freshByID (post-recoverMissingFromList) + fresh Bead + freshDeps []Dep // depsForReconcileLocked output, computed by caller + cachedExists bool // id present in c.beads + cached Bead + cachedDeps []Dep // c.deps[id] value (nil when absent) + hasCachedDeps bool // c.deps[id] presence — distinct from nil/empty value + deletedAtSeq uint64 + beadAtSeq uint64 + startSeq uint64 + localAt time.Time + now time.Time // the single pass-level clock read + skipLabels bool +} + +// reconcileMergeDecision decides the fate of one id's state transition in the +// collapsed reconcile: the absorb loop, the eviction loop, and the fence/deps +// GC sweep all route through it. It is pure — no receiver, no locks, no map +// mutation, no clock reads, no I/O — so it is exhaustively enumerable and +// trivially comparable in the differential gate. The fence ordering in each +// case is tombstone/seq fence beats recency beats mutate. +func reconcileMergeDecision(in mergeRowInput) mergeDecision { + switch { + case in.freshExists: // absorb-loop cell + if in.deletedAtSeq > in.startSeq || in.beadAtSeq > in.startSeq { + return mergeDecision{ + action: mergeSkipFenced, + degradeDepsComplete: in.cachedExists && !in.hasCachedDeps, + } + } + if in.cachedExists && + recentLocalMutation(in.localAt, in.now) && + beadChanged(in.cached, in.fresh, in.skipLabels) { + return mergeDecision{ + action: mergeSkipRecentLocal, + degradeDepsComplete: !in.hasCachedDeps || depsChanged(in.cachedDeps, in.freshDeps), + } + } + n := "" + switch { + case !in.cachedExists: + n = "bead.created" + case beadChanged(in.cached, in.fresh, in.skipLabels): + n = "bead.updated" + case depsChanged(in.cachedDeps, in.freshDeps): + n = "bead.updated" + } + return mergeDecision{action: mergeAbsorb, notification: n} + + case in.cachedExists: // eviction-loop cell (id absent from snapshot) + if in.deletedAtSeq > in.startSeq || in.beadAtSeq > in.startSeq { + return mergeDecision{action: mergeSkipFenced} + } + if in.cached.Status != "closed" && recentLocalMutation(in.localAt, in.now) { + return mergeDecision{action: mergeSkipRecentLocal} + } + n := "" + if in.cached.Status != "closed" { + n = "bead.closed" + } + return mergeDecision{action: mergeEvict, notification: n} + + default: // fence-GC cell (no row on either side; orphan fence/deps only) + if in.deletedAtSeq > in.startSeq || in.beadAtSeq > in.startSeq { + return mergeDecision{action: mergeSkipFenced} + } + if recentLocalMutation(in.localAt, in.now) { + return mergeDecision{action: mergeSkipRecentLocal} + } + return mergeDecision{action: mergeGCFences} + } +} + +// mergeSectionResult carries the deterministic outputs of mergeSnapshotLocked +// back to runReconciliation: the notifications to emit after unlock and the +// per-pass add/remove/update counts. +type mergeSectionResult struct { + notifications []cacheNotification + adds int64 + removes int64 + updates int64 +} + +// mergeSnapshotLocked applies a full-scan snapshot to the cache under c.mu. +// It is the deterministic seam of runReconciliation: pure in-memory, no I/O, +// no clock reads (now injected), no notifications emitted (returned for the +// caller to emit after unlock). Every per-id fate is decided by +// reconcileMergeDecision; the three index sets it iterates (freshByID, the +// cached rows absent from freshByID, and the orphan fence/deps ids) are +// pairwise disjoint, so the passes cannot perturb each other. Caller must hold +// c.mu (write lock). +func (c *CachingStore) mergeSnapshotLocked( + freshByID map[string]Bead, confirmedClosed map[string]Bead, + depMap map[string][]Dep, useFreshDeps bool, + startSeq uint64, now time.Time, +) mergeSectionResult { // Preserve a cached is_blocked for any row the projection did not return // this cycle. Two cases land here: a full projection failure (enrichErr // left every row unenriched) and the narrower race where a row is still @@ -341,204 +497,168 @@ func (c *CachingStore) runReconciliation() { // the row's is_blocked flips false->nil and beadChanged emits a spurious // bead.updated. The guards inside drop the preservation when the row's deps // or a blocking target's status actually changed, so a real transition is - // never masked. + // never masked. Runs first, on pre-merge state, because it reads other + // rows' cached status. c.preserveCachedReadyProjectionLocked(freshByID, depMap, useFreshDeps) - if c.mutationSeq != startSeq { - var adds, removes, updates int64 - notifications := make([]cacheNotification, 0, len(freshByID)) - nextDepsComplete := useFreshDeps - - for id, freshBead := range freshByID { - if c.deletedSeq[id] > startSeq || c.beadSeq[id] > startSeq { - if _, exists := c.beads[id]; exists { - if _, ok := c.deps[id]; !ok { - nextDepsComplete = false - } - } - continue - } - if _, keep := c.recentLocalBeadConflictLocked(id, freshBead, now, true); keep { - if _, ok := c.deps[id]; !ok { - nextDepsComplete = false - } - continue - } - freshDeps := c.depsForReconcileLocked(id, freshBead, depMap, useFreshDeps) - - old, exists := c.beads[id] - switch { - case !exists: - adds++ - notifications = append(notifications, cacheNotification{ - eventType: "bead.created", - bead: cloneBead(freshBead), - }) - case beadChanged(old, freshBead, true): - updates++ - notifications = append(notifications, cacheNotification{ - eventType: "bead.updated", - bead: cloneBead(freshBead), - }) - case depsChanged(c.deps[id], freshDeps): - updates++ - notifications = append(notifications, cacheNotification{ - eventType: "bead.updated", - bead: cloneBead(freshBead), - }) - } - c.absorbFreshLocked(id, freshBead, now, absorbOpts{ - depsMode: depsExplicit, - deps: freshDeps, - seqMode: seqClearGuarded, - clearDirty: true, - }) - } - - for id, old := range c.beads { - if _, exists := freshByID[id]; exists { - continue - } - if c.deletedSeq[id] > startSeq || c.beadSeq[id] > startSeq { - continue - } - if old.Status != "closed" && recentLocalMutation(c.localBeadAt[id], now) { - continue - } - removes++ - if old.Status != "closed" { - closed := cloneBead(old) - closed.Status = "closed" - if freshClosed, ok := confirmedClosed[id]; ok { - closed = cloneBead(freshClosed) - } - notifications = append(notifications, cacheNotification{ - eventType: "bead.closed", - bead: closed, - }) - } - c.evictLocked(id) - } - - c.syncFailures = 0 - c.depsComplete = nextDepsComplete - c.primePartialErr = nil - c.promoteLiveLocked() - durMs := float64(time.Since(start).Microseconds()) / 1000.0 - c.stats.LastReconcileAt = now - c.stats.LastReconcileMs = durMs - c.stats.Adds += adds - c.stats.Removes += removes - c.stats.Updates += updates - c.markFreshLocked(now) - c.recordReconcileLatencyLocked(bdLatency) - c.recomputeCadenceLocked() - c.updateStatsLocked() - logLine, emit := c.reconcileSuccessLogLocked(now, time.Since(start), adds, removes, updates) - c.mu.Unlock() - if emit { - log.Print(logLine) - } - c.notifyChanges(notifications) - return - } - - var adds, removes, updates int64 - notifications := make([]cacheNotification, 0, len(freshByID)) - nextBeads := make(map[string]Bead, len(freshByID)) - nextDeps := make(map[string][]Dep, len(freshByID)) - nextDirty := make(map[string]struct{}) - nextBeadSeq := make(map[string]uint64) - nextLocalBeadAt := make(map[string]time.Time) + res := mergeSectionResult{notifications: make([]cacheNotification, 0, len(freshByID))} + nextDepsComplete := useFreshDeps + // 1. Absorb loop — over freshByID. Classification reads pre-absorb state. for id, freshBead := range freshByID { - beadForCache := freshBead - preservedRecentLocal := false - if current, keep := c.recentLocalBeadConflictLocked(id, freshBead, now, true); keep { - beadForCache = current - preservedRecentLocal = true - c.carryRecentLocalMutationLocked(id, nextDirty, nextBeadSeq, nextLocalBeadAt) - } freshDeps := c.depsForReconcileLocked(id, freshBead, depMap, useFreshDeps) - nextBeads[id] = cloneBead(beadForCache) - nextDeps[id] = cloneDeps(freshDeps) - - old, exists := c.beads[id] - switch { - case !exists: - adds++ - notifications = append(notifications, cacheNotification{ + cached, cachedExists := c.beads[id] + cachedDeps, hasCachedDeps := c.deps[id] + d := reconcileMergeDecision(mergeRowInput{ + freshExists: true, + fresh: freshBead, + freshDeps: freshDeps, + cachedExists: cachedExists, + cached: cached, + cachedDeps: cachedDeps, + hasCachedDeps: hasCachedDeps, + deletedAtSeq: c.deletedSeq[id], + beadAtSeq: c.beadSeq[id], + startSeq: startSeq, + localAt: c.localBeadAt[id], + now: now, + skipLabels: true, + }) + if d.degradeDepsComplete { + nextDepsComplete = false + } + if d.action != mergeAbsorb { + continue + } + switch d.notification { + case "bead.created": + res.adds++ + res.notifications = append(res.notifications, cacheNotification{ eventType: "bead.created", - bead: cloneBead(beadForCache), - }) - case !preservedRecentLocal && beadChanged(old, freshBead, true): - updates++ - notifications = append(notifications, cacheNotification{ - eventType: "bead.updated", bead: cloneBead(freshBead), }) - case !preservedRecentLocal && depsChanged(c.deps[id], freshDeps): - updates++ - notifications = append(notifications, cacheNotification{ + case "bead.updated": + res.updates++ + res.notifications = append(res.notifications, cacheNotification{ eventType: "bead.updated", bead: cloneBead(freshBead), }) } - } - - for id, old := range c.beads { - if _, exists := freshByID[id]; !exists { - if old.Status != "closed" && recentLocalMutation(c.localBeadAt[id], now) { - nextBeads[id] = cloneBead(old) - if deps, ok := c.deps[id]; ok { - nextDeps[id] = cloneDeps(deps) - } - c.carryRecentLocalMutationLocked(id, nextDirty, nextBeadSeq, nextLocalBeadAt) - continue - } - removes++ - if old.Status == "closed" { - continue - } - closed := cloneBead(old) + c.absorbFreshLocked(id, freshBead, now, absorbOpts{ + depsMode: depsExplicit, + deps: freshDeps, + seqMode: seqClearGuarded, + clearDirty: true, + }) + } + + // 2. Eviction loop — over c.beads \ freshByID. Deleting the current key + // inside range c.beads is safe per the Go spec. + for id, cached := range c.beads { + if _, exists := freshByID[id]; exists { + continue + } + d := reconcileMergeDecision(mergeRowInput{ + freshExists: false, + cachedExists: true, + cached: cached, + deletedAtSeq: c.deletedSeq[id], + beadAtSeq: c.beadSeq[id], + startSeq: startSeq, + localAt: c.localBeadAt[id], + now: now, + skipLabels: true, + }) + if d.action != mergeEvict { + continue + } + res.removes++ + if d.notification == "bead.closed" { + closed := cloneBead(cached) closed.Status = "closed" if freshClosed, ok := confirmedClosed[id]; ok { closed = cloneBead(freshClosed) } - notifications = append(notifications, cacheNotification{ + res.notifications = append(res.notifications, cacheNotification{ eventType: "bead.closed", bead: closed, }) } + c.evictLocked(id) + } + + // 3. Fence/deps-GC sweep — over orphan ids (a fence or deps entry with no + // row on either side). Replaces Branch B's implicit wholesale reset: + // stale orphans are collected, recent ones kept one more cycle. The id + // set is snapshotted before deleting to avoid iterate-while-delete. + for _, id := range c.orphanFenceIDsLocked(freshByID) { + d := reconcileMergeDecision(mergeRowInput{ + freshExists: false, + cachedExists: false, + deletedAtSeq: c.deletedSeq[id], + beadAtSeq: c.beadSeq[id], + startSeq: startSeq, + localAt: c.localBeadAt[id], + now: now, + skipLabels: true, + }) + if d.action != mergeGCFences { + continue + } + delete(c.deletedSeq, id) + delete(c.dirty, id) + delete(c.beadSeq, id) + delete(c.localBeadAt, id) + delete(c.deps, id) } - c.beads = nextBeads - c.deps = nextDeps - c.depsComplete = useFreshDeps - c.dirty = nextDirty - c.beadSeq = nextBeadSeq - c.localBeadAt = nextLocalBeadAt - c.deletedSeq = make(map[string]uint64) + // 4. Shared tail (was duplicated per branch). c.syncFailures = 0 + c.depsComplete = nextDepsComplete c.primePartialErr = nil c.promoteLiveLocked() - - durMs := float64(time.Since(start).Microseconds()) / 1000.0 c.stats.LastReconcileAt = now - c.stats.LastReconcileMs = durMs - c.stats.Adds += adds - c.stats.Removes += removes - c.stats.Updates += updates + c.stats.Adds += res.adds + c.stats.Removes += res.removes + c.stats.Updates += res.updates c.markFreshLocked(now) - c.recordReconcileLatencyLocked(bdLatency) - c.recomputeCadenceLocked() - c.updateStatsLocked() - logLine, emit := c.reconcileSuccessLogLocked(now, time.Since(start), adds, removes, updates) - c.mu.Unlock() - if emit { - log.Print(logLine) + return res +} + +// orphanFenceIDsLocked returns the ids carrying a fence or deps entry but no +// cached row and no fresh row this cycle — the fence/deps-GC sweep's work set. +// Caller must hold c.mu. +func (c *CachingStore) orphanFenceIDsLocked(freshByID map[string]Bead) []string { + seen := make(map[string]struct{}) + add := func(id string) { + if _, ok := c.beads[id]; ok { + return + } + if _, ok := freshByID[id]; ok { + return + } + seen[id] = struct{}{} + } + for id := range c.deletedSeq { + add(id) + } + for id := range c.dirty { + add(id) + } + for id := range c.beadSeq { + add(id) + } + for id := range c.localBeadAt { + add(id) + } + for id := range c.deps { + add(id) + } + ids := make([]string, 0, len(seen)) + for id := range seen { + ids = append(ids, id) } - c.notifyChanges(notifications) + return ids } // promoteLiveLocked marks the cache live after a clean full-scan diff --git a/internal/beads/caching_store_reconcile_census_test.go b/internal/beads/caching_store_reconcile_census_test.go new file mode 100644 index 0000000000..7a0f5da2b8 --- /dev/null +++ b/internal/beads/caching_store_reconcile_census_test.go @@ -0,0 +1,128 @@ +package beads + +import ( + "os" + "path/filepath" + "reflect" + "regexp" + "strings" + "testing" +) + +// Writers census (plan §6.1 leg 1): the collapsed reconcile's regime invariant +// Q — quiescent ⇒ no fence value exceeds startSeq — rests on every fence VALUE +// being minted by a post-increment of mutationSeq under c.mu. This test proves +// the only sites that assign a fence map (index-assign a value, or replace the +// whole map) are the sanctioned ones, so a future bypass in reconcile (or a +// resurrected Branch B) fails the build. Extended per the council's V-soundness +// nit to also match whole-map replacement, not just indexed writes. +func TestReconcileFenceWritersCensus(t *testing.T) { + files := packageGoFiles(t) + + indexAssign := regexp.MustCompile(`c\.(beadSeq|deletedSeq|localBeadAt)\[[^\]]+\]\s*=[^=]`) + wholeAssign := regexp.MustCompile(`c\.(beadSeq|deletedSeq|localBeadAt)\s*=[^=]`) + + // Allowed enclosing functions for index-assignments (value minting / setting). + allowedIndex := map[string]bool{ + "noteMutationLocked": true, // beadSeq + "noteLocalMutationLocked": true, // localBeadAt + "tombstoneLocked": true, // deletedSeq + } + // Allowed enclosing functions for whole-map replacement. Only prime()'s + // own B-shaped rebuild remains after the Phase-2 collapse deleted reconcile + // Branch B; if reconcile ever regrows a wholesale fence reset, this fails. + allowedWhole := map[string]bool{ + "prime": true, + } + + for _, f := range files { + src, err := os.ReadFile(f) + if err != nil { + t.Fatalf("read %s: %v", f, err) + } + fn := "" + funcRe := regexp.MustCompile(`^func (?:\([^)]*\) )?([A-Za-z0-9_]+)`) + for i, line := range strings.Split(string(src), "\n") { + if m := funcRe.FindStringSubmatch(line); m != nil { + fn = m[1] + } + if indexAssign.MatchString(line) && !allowedIndex[fn] { + t.Errorf("%s:%d fence index-assignment in unsanctioned func %q: %s", + filepath.Base(f), i+1, fn, strings.TrimSpace(line)) + } + if wholeAssign.MatchString(line) && !allowedWhole[fn] { + t.Errorf("%s:%d whole-map fence assignment in unsanctioned func %q: %s", + filepath.Base(f), i+1, fn, strings.TrimSpace(line)) + } + } + } +} + +func packageGoFiles(t *testing.T) []string { + t.Helper() + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("readdir: %v", err) + } + var out []string + for _, e := range entries { + n := e.Name() + if strings.HasSuffix(n, ".go") && !strings.HasSuffix(n, "_test.go") { + out = append(out, n) + } + } + if len(out) == 0 { + t.Fatal("no package source files found") + } + return out +} + +// Field-coverage census (plan §5.1 hardening): the oracle's end-state +// comparison must be structurally exhaustive. Every CachingStore and CacheStats +// field is either compared by the oracle or on a justified-exclusion list; a +// field added later that is neither fails this test, forcing a conscious +// classification instead of a silent oracle blind spot. +func TestMergeOracleFieldCoverage(t *testing.T) { + comparedStore := map[string]bool{ + "beads": true, "deps": true, "depsComplete": true, "dirty": true, + "beadSeq": true, "localBeadAt": true, "deletedSeq": true, "state": true, + "lastFreshAt": true, "mutationSeq": true, "primePartialErr": true, + "syncFailures": true, "stats": true, // stats compared field-wise below + } + excludedStore := map[string]bool{ + "backing": true, "idPrefix": true, "mu": true, "reconciling": true, + "onChange": true, "problemf": true, "problemLog": true, + "lastReconcileLogAt": true, "primeMu": true, "primeRunning": true, + "primeCycle": true, "lastFullPrimeStartedAt": true, "primeRetryDelay": true, + "lifecycleMu": true, "lifecycleWG": true, "cancelFn": true, "stopCh": true, + "stopped": true, "latencyWindow": true, "latencyDriverActive": true, + "applyEventBeforeCommitForTest": true, + } + assertFieldsClassified(t, reflect.TypeOf(CachingStore{}), comparedStore, excludedStore) + + comparedStats := map[string]bool{ + "LastFreshAt": true, "LastReconcileAt": true, + "Adds": true, "Removes": true, "Updates": true, + } + excludedStats := map[string]bool{ + "TotalBeads": true, "TotalDeps": true, "LastReconcileMs": true, + "ReconcileRecoveries": true, "ReconcileCloseDeferrals": true, + "SyncFailures": true, "ProblemCount": true, "LastProblemAt": true, + "LastProblem": true, "State": true, "StaggerOffsetMs": true, + "CurrentReconcileInterval": true, "LatencyP95Ms": true, "CadenceDriver": true, + } + assertFieldsClassified(t, reflect.TypeOf(CacheStats{}), comparedStats, excludedStats) +} + +func assertFieldsClassified(t *testing.T, ty reflect.Type, compared, excluded map[string]bool) { + t.Helper() + for i := 0; i < ty.NumField(); i++ { + name := ty.Field(i).Name + if !compared[name] && !excluded[name] { + t.Errorf("%s.%s is neither compared nor justified-excluded by the merge oracle — classify it (a seam-written field must be compared)", ty.Name(), name) + } + if compared[name] && excluded[name] { + t.Errorf("%s.%s is in both compared and excluded sets", ty.Name(), name) + } + } +} diff --git a/internal/beads/caching_store_reconcile_coverage_test.go b/internal/beads/caching_store_reconcile_coverage_test.go new file mode 100644 index 0000000000..19ff8912d6 --- /dev/null +++ b/internal/beads/caching_store_reconcile_coverage_test.go @@ -0,0 +1,332 @@ +package beads + +import ( + "fmt" + "sort" + "sync" + "time" +) + +// coverageKey is the discretized per-row decision cell. The guard dimensions +// (regime, presence, tomb, seqFence, recency, changed) are the axes the +// user-visible guards actually branch on; the differential gate requires the +// full V-filtered cross of those. The remaining "soft" dimensions prove +// insensitivity / exercise the council-hardened sub-axes and are required only +// marginally (each value observed at least once). +type coverageKey struct { + regime string + presence string + tomb string + seqFence string + recency string + changed string + statusPair string + + // Soft / hardened axes (marginal coverage). + dirty bool + depMapCell string // fresh deps via depMap (useFreshDeps): na|nil|empty|nonempty + fieldDeps string // council axis-9 split: fresh bead dep fields: na|none|needs|deps|both + cachedDeps string // cached c.deps[id]: absent|nil|empty|nonempty + useFreshDeps bool + backingIsBd bool + confirmedClosed bool + preserveOutcome string +} + +// guardCell is the projection over the guard-critical axes; the gate requires +// full V-filtered cross occupancy over these. +type guardCell struct { + regime, presence, tomb, seqFence, recency, changed, statusPair string +} + +func (k coverageKey) guard() guardCell { + return guardCell{k.regime, k.presence, k.tomb, k.seqFence, k.recency, k.changed, k.statusPair} +} + +// coverageRecorder accumulates observed cells across generator tiers. +type coverageRecorder struct { + mu sync.Mutex + guards map[guardCell]int + marginal map[string]int // "axis=value" → count + full map[coverageKey]int +} + +func newCoverageRecorder() *coverageRecorder { + return &coverageRecorder{ + guards: map[guardCell]int{}, + marginal: map[string]int{}, + full: map[coverageKey]int{}, + } +} + +func (r *coverageRecorder) record(k coverageKey) { + r.mu.Lock() + defer r.mu.Unlock() + r.guards[k.guard()]++ + r.full[k]++ + r.marginal[fmt.Sprintf("dirty=%v", k.dirty)]++ + r.marginal["depMapCell="+k.depMapCell]++ + r.marginal["fieldDeps="+k.fieldDeps]++ + r.marginal["cachedDeps="+k.cachedDeps]++ + r.marginal[fmt.Sprintf("useFreshDeps=%v", k.useFreshDeps)]++ + r.marginal[fmt.Sprintf("backingIsBd=%v", k.backingIsBd)]++ + r.marginal[fmt.Sprintf("confirmedClosed=%v", k.confirmedClosed)]++ + r.marginal["preserveOutcome="+k.preserveOutcome]++ + r.marginal["recency="+k.recency]++ + r.marginal["changed="+k.changed]++ + r.marginal["tomb="+k.tomb]++ + r.marginal["seqFence="+k.seqFence]++ + r.marginal["presence="+k.presence]++ + r.marginal["regime="+k.regime]++ +} + +// classifyRow derives the coverageKey for id from the INPUT (st, in). The +// id universe callers use is the union of all six maps plus freshByID, so +// deps-only orphans classify too. +func classifyRow(st storeState, in snapshotInputs, id string) coverageKey { + k := coverageKey{} + if in.quiescent(st) { + k.regime = "quiescent" + } else { + k.regime = "mutated" + } + fresh, f := in.freshByID[id] + cached, c := st.beads[id] + switch { + case f && c: + k.presence = "both" + case f: + k.presence = "snap" + case c: + k.presence = "cache" + default: + k.presence = "neither" + } + k.tomb = fenceCell(st.deletedSeq, id, in.startSeq) + k.seqFence = fenceCell(st.beadSeq, id, in.startSeq) + k.recency = recencyCell(st.localBeadAt, id, in.now) + + postPreserve := computePostPreserveFresh(st, in) + pf := postPreserve[id] + switch { + case f && c: + k.changed = changedCell(cached, pf) + k.statusPair = cached.Status + ">" + fresh.Status + case f: + k.changed = "na" + k.statusPair = "?>" + fresh.Status + case c: + k.changed = "na" + k.statusPair = cached.Status + ">?" + default: + k.changed = "na" + k.statusPair = "na" + } + + _, k.dirty = st.dirty[id] + k.useFreshDeps = in.useFreshDeps + k.backingIsBd = st.backingIsBd + if in.useFreshDeps { + k.depMapCell = depSliceCell(in.depMap, id) + k.fieldDeps = "na" + } else { + k.depMapCell = "na" + k.fieldDeps = fieldDepsCell(fresh, f) + } + k.cachedDeps = depSliceCell(st.deps, id) + _, k.confirmedClosed = in.confirmedClosed[id] + k.preserveOutcome = preserveOutcomeCell(st, in, id) + return k +} + +func fenceCell(m map[string]uint64, id string, startSeq uint64) string { + v, ok := m[id] + if !ok { + return "none" + } + switch { + case v < startSeq: + return "lt" + case v == startSeq: + return "eq" + default: + return "gt" + } +} + +func recencyCell(m map[string]time.Time, id string, now time.Time) string { + t, ok := m[id] + if !ok || t.IsZero() { + return "none" + } + d := now.Sub(t) + switch { + case d <= 0: + return "now" + case d <= 2500*millis: + return "recent" + case d <= 5000*millis: + return "boundary" + case d <= 5001*millis: + return "justover" + default: + return "stale" + } +} + +const millis = 1000000 // time.Millisecond in ns as a bare constant for arithmetic + +func changedCell(cached, fresh Bead) string { + if !beadChanged(cached, fresh, true) { + // Distinguish labels-only (skipLabels=true masks it) from truly equal. + if !slicesEqualStr(cached.Labels, fresh.Labels) { + return "labels" + } + return "equal" + } + switch { + case cached.Status != fresh.Status: + return "status" + case !boolPtrEqual(cached.IsBlocked, fresh.IsBlocked): + return "isblocked" + case !mapsEqualStr(cached.Metadata, fresh.Metadata): + return "metadata" + case !slicesEqualStr(cached.Needs, fresh.Needs): + return "needs" + case !depsSliceEqual(cached.Dependencies, fresh.Dependencies): + return "depsfield" + default: + return "other" + } +} + +func depSliceCell(m map[string][]Dep, id string) string { + v, ok := m[id] + if !ok { + return "absent" + } + if v == nil { + return "nil" + } + if len(v) == 0 { + return "empty" + } + return "nonempty" +} + +func fieldDepsCell(b Bead, present bool) string { + if !present { + return "na" + } + hasNeeds := len(b.Needs) > 0 + hasDeps := len(b.Dependencies) > 0 + switch { + case hasNeeds && hasDeps: + return "both" + case hasNeeds: + return "needs" + case hasDeps: + return "deps" + default: + return "none" + } +} + +// preserveOutcomeCell re-runs the preserve eligibility predicate on the INPUT +// state (council input-coverage hardening), using the shared helpers. +func preserveOutcomeCell(st storeState, in snapshotInputs, id string) string { + item, ok := in.freshByID[id] + if !ok { + return "na" + } + if item.IsBlocked != nil { + return "inapplicable" + } + cached, cok := st.beads[id] + if !cok || cached.IsBlocked == nil { + return "no-cached" + } + c, _ := newMergeHarnessStore(st) + c.mu.Lock() + defer c.mu.Unlock() + freshDeps := c.depsForReconcileLocked(id, item, in.depMap, in.useFreshDeps) + if depsChanged(c.deps[id], freshDeps) { + return "blocked-deps" + } + if c.readyBlockingDependencyTargetStatusChangedLocked(freshDeps, in.freshByID) { + return "blocked-target" + } + return "applied" +} + +// --- small comparison helpers (test-local, avoid importing maps/slices) --- + +func slicesEqualStr(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func mapsEqualStr(a, b StringMap) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if bv, ok := b[k]; !ok || bv != v { + return false + } + } + return true +} + +func depsSliceEqual(a, b []Dep) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// rowIDUniverse is the maximal id set for classification/oracle iteration: the +// union of all six maps plus freshByID (council delete-B hardening — deps-only +// orphans must classify). +func rowIDUniverse(st storeState, in snapshotInputs) []string { + set := map[string]struct{}{} + for id := range st.beads { + set[id] = struct{}{} + } + for id := range st.deps { + set[id] = struct{}{} + } + for id := range st.dirty { + set[id] = struct{}{} + } + for id := range st.beadSeq { + set[id] = struct{}{} + } + for id := range st.localBeadAt { + set[id] = struct{}{} + } + for id := range st.deletedSeq { + set[id] = struct{}{} + } + for id := range in.freshByID { + set[id] = struct{}{} + } + out := make([]string, 0, len(set)) + for id := range set { + out = append(out, id) + } + sort.Strings(out) + return out +} diff --git a/internal/beads/caching_store_reconcile_decision_test.go b/internal/beads/caching_store_reconcile_decision_test.go new file mode 100644 index 0000000000..70ee21c59e --- /dev/null +++ b/internal/beads/caching_store_reconcile_decision_test.go @@ -0,0 +1,165 @@ +package beads + +import ( + "testing" + "time" +) + +// T4: exhaustive test of the pure reconcileMergeDecision over its input +// lattice, plus the §1.2 structural invariants. +// +// Scope of the oracle: expectedDecision is a hand-transcription of the same +// decision table the production switch encodes, so this test is DRIFT and +// STRUCTURAL-INVARIANT coverage — it catches an accidental future edit that +// moves one function out of step with the other, and the invariants pin +// type-level properties the switch must uphold on every lattice point. It is +// deliberately NOT an independent semantic oracle: a spec misunderstanding +// baked into both functions would pass here. The independent semantic ground +// truth is the frozen Branch A / Branch B differential gate +// (caching_store_reconcile_differential_test.go), which runs the real +// pre-collapse bodies and would fail on a wrong decision; this table sits on +// top of that as cheap, exhaustive drift protection. + +func expectedDecision(in mergeRowInput) mergeDecision { + switch { + case in.freshExists: + if in.deletedAtSeq > in.startSeq || in.beadAtSeq > in.startSeq { + return mergeDecision{action: mergeSkipFenced, degradeDepsComplete: in.cachedExists && !in.hasCachedDeps} + } + if in.cachedExists && recentLocalMutation(in.localAt, in.now) && beadChanged(in.cached, in.fresh, in.skipLabels) { + return mergeDecision{action: mergeSkipRecentLocal, degradeDepsComplete: !in.hasCachedDeps || depsChanged(in.cachedDeps, in.freshDeps)} + } + n := "" + switch { + case !in.cachedExists: + n = "bead.created" + case beadChanged(in.cached, in.fresh, in.skipLabels): + n = "bead.updated" + case depsChanged(in.cachedDeps, in.freshDeps): + n = "bead.updated" + } + return mergeDecision{action: mergeAbsorb, notification: n} + case in.cachedExists: + if in.deletedAtSeq > in.startSeq || in.beadAtSeq > in.startSeq { + return mergeDecision{action: mergeSkipFenced} + } + if in.cached.Status != "closed" && recentLocalMutation(in.localAt, in.now) { + return mergeDecision{action: mergeSkipRecentLocal} + } + n := "" + if in.cached.Status != "closed" { + n = "bead.closed" + } + return mergeDecision{action: mergeEvict, notification: n} + default: + if in.deletedAtSeq > in.startSeq || in.beadAtSeq > in.startSeq { + return mergeDecision{action: mergeSkipFenced} + } + if recentLocalMutation(in.localAt, in.now) { + return mergeDecision{action: mergeSkipRecentLocal} + } + return mergeDecision{action: mergeGCFences} + } +} + +func TestReconcileMergeDecision_Exhaustive(t *testing.T) { + const startSeq = uint64(100) + now := fxNow + seqVals := []uint64{0, 99, 100, 101} + recVals := []time.Time{{}, fxRecent(), fxBoundary(), fxJustOver(), fxStale()} + // Beads chosen to drive beadChanged both ways under each status. + beadOpen := bead("x", "open") + beadOpenChanged := beadWith("x", "open", func(b *Bead) { b.Title = "changed" }) + beadClosed := bead("x", "closed") + beadInProg := bead("x", "in_progress") + beadSet := []Bead{beadOpen, beadOpenChanged, beadClosed, beadInProg} + depSet := [][]Dep{nil, {dep("x", "d1")}} + + var count int + for _, fe := range []bool{true, false} { + for _, ce := range []bool{true, false} { + for _, fresh := range beadSet { + for _, cached := range beadSet { + for _, fdeps := range depSet { + for _, cdeps := range depSet { + for _, hcd := range []bool{true, false} { + for _, del := range seqVals { + for _, bs := range seqVals { + for _, rec := range recVals { + for _, skip := range []bool{true, false} { + in := mergeRowInput{ + freshExists: fe, + fresh: fresh, + freshDeps: fdeps, + cachedExists: ce, + cached: cached, + cachedDeps: cdeps, + hasCachedDeps: hcd, + deletedAtSeq: del, + beadAtSeq: bs, + startSeq: startSeq, + localAt: rec, + now: now, + skipLabels: skip, + } + got := reconcileMergeDecision(in) + want := expectedDecision(in) + if got != want { + t.Fatalf("decision mismatch\n in=%+v\n got=%+v\n want=%+v", in, got, want) + } + assertDecisionInvariants(t, in, got) + count++ + } + } + } + } + } + } + } + } + } + } + } + if count < 10000 { + t.Fatalf("lattice too small: %d points", count) + } +} + +func assertDecisionInvariants(t *testing.T, in mergeRowInput, d mergeDecision) { + t.Helper() + // INV-A: an uncached absorb-cell row can never yield mergeSkipRecentLocal. + if in.freshExists && !in.cachedExists && d.action == mergeSkipRecentLocal { + t.Fatalf("uncached absorb cell yielded mergeSkipRecentLocal: %+v", in) + } + // INV-B: mergeGCFences only when both rows absent. + if d.action == mergeGCFences && (in.freshExists || in.cachedExists) { + t.Fatalf("mergeGCFences with a present row: %+v", in) + } + // INV-C: degradeDepsComplete is only ever set on absorb-cell skip arms. + if d.degradeDepsComplete { + absorbCellSkip := in.freshExists && + (d.action == mergeSkipFenced || d.action == mergeSkipRecentLocal) + if !absorbCellSkip { + t.Fatalf("degradeDepsComplete set outside an absorb-cell skip arm: in=%+v d=%+v", in, d) + } + } + // INV-D: eviction-cell never degrades depsComplete. + if !in.freshExists && in.cachedExists && d.degradeDepsComplete { + t.Fatalf("eviction cell degraded depsComplete: %+v", in) + } + // INV-E: notifications only accompany their action. + switch d.action { + case mergeAbsorb: + if d.notification != "" && d.notification != "bead.created" && d.notification != "bead.updated" { + t.Fatalf("absorb produced notification %q", d.notification) + } + case mergeEvict: + if d.notification != "" && d.notification != "bead.closed" { + t.Fatalf("evict produced notification %q", d.notification) + } + default: + if d.notification != "" { + t.Fatalf("action %v produced notification %q", d.action, d.notification) + } + } +} diff --git a/internal/beads/caching_store_reconcile_differential_test.go b/internal/beads/caching_store_reconcile_differential_test.go new file mode 100644 index 0000000000..40c6226927 --- /dev/null +++ b/internal/beads/caching_store_reconcile_differential_test.go @@ -0,0 +1,554 @@ +package beads + +// Differential gate for the S01 Phase-2 reconcile collapse. +// +// The fleet-critical beads read cache reconciles a full-scan snapshot into six +// in-memory maps. Before Phase 2 this was two branches: Branch A (per-row +// in-place merge, taken when a local write raced the scan) and Branch B +// (whole-map rebuild, taken in the quiescent regime). Phase 2 collapses both +// into a single pipeline routed through the pure reconcileMergeDecision plus a +// fence/deps-GC sweep. +// +// A merge divergence does not crash — it silently serves stale or wrong beads +// to every agent (#2987 class). This gate is the sole quality assurance for +// the collapse. It runs the FROZEN legacy Branch A and Branch B bodies and the +// LIVE collapsed seam on byte-identical inputs and asserts their end-states are +// identical modulo the exactly-enumerated §2 deltas (D1, D1', D2, D3, D3', D4, +// D5), over a provably-covered decision space. +// +// Provenance of the frozen copies: mechanical transliterations of +// runReconciliation's two branches at the pre-collapse commit 84c010a1b +// (internal/beads/caching_store_reconcile.go lines 346-542), extracted +// 2026-07-08. They call the REAL in-package helpers (recentLocalBeadConflictLocked, +// depsForReconcileLocked, carryRecentLocalMutationLocked, +// preserveCachedReadyProjectionLocked, beadChanged, depsChanged, cloneBead, +// cloneDeps, absorbFreshLocked, evictLocked) so helper semantics are shared by +// construction; the differential surface is exactly the branch structure being +// collapsed. Scope: this gate proves branch-structure equivalence GIVEN shared +// helpers; helper semantics are pinned separately by the white-box suite + T4. +// +// DO NOT edit or delete the frozen copies. They are the ongoing guard: every +// CI run re-proves the collapsed loop against Branch A/B semantics. + +import ( + "reflect" + "time" +) + +// --------------------------------------------------------------------------- +// Harness state types +// --------------------------------------------------------------------------- + +// storeState is the pre-merge cache state the seam reads: the six per-row maps +// plus the two scalars that steer it (depsComplete is written, mutationSeq +// selects the OLD regime). backingIsBd drives depsForReconcileLocked's +// off-BdStore cached-deps fallback and is a shared input to all three +// implementations. +type storeState struct { + beads map[string]Bead + deps map[string][]Dep + depsComplete bool + dirty map[string]struct{} + beadSeq map[string]uint64 + localBeadAt map[string]time.Time + deletedSeq map[string]uint64 + mutationSeq uint64 + backingIsBd bool +} + +// snapshotInputs is the seam's argument tuple (mergeSnapshotLocked's params). +type snapshotInputs struct { + freshByID map[string]Bead + confirmedClosed map[string]Bead + depMap map[string][]Dep + useFreshDeps bool + startSeq uint64 + now time.Time +} + +// quiescent reports whether the OLD selector would take Branch B. +func (in snapshotInputs) quiescent(st storeState) bool { + return st.mutationSeq == in.startSeq +} + +// mergeEndState is the deterministic post-merge cache state the oracle compares. +// It captures every field the seam writes; the field-coverage census +// (TestMergeOracleFieldCoverage) proves this list stays exhaustive. +type mergeEndState struct { + beads map[string]Bead + deps map[string][]Dep + depsComplete bool + dirty map[string]struct{} + beadSeq map[string]uint64 + localBeadAt map[string]time.Time + deletedSeq map[string]uint64 + state cacheState + lastFreshAt time.Time + mutationSeq uint64 + primeErr string + syncFailures int + // stats fields the seam writes. + statsAdds int64 + statsRemoves int64 + statsUpdates int64 + statsLastReconcileAt time.Time + statsLastFreshAt time.Time +} + +// --------------------------------------------------------------------------- +// Deep clone (so the three runs see byte-identical, independent inputs) +// --------------------------------------------------------------------------- + +func cloneBeadMap(m map[string]Bead) map[string]Bead { + if m == nil { + return nil + } + out := make(map[string]Bead, len(m)) + for k, v := range m { + out[k] = cloneBead(v) + } + return out +} + +func cloneDepMap(m map[string][]Dep) map[string][]Dep { + if m == nil { + return nil + } + out := make(map[string][]Dep, len(m)) + for k, v := range m { + // Match the production cloneDeps helper: an empty entry (nil or []Dep{}) + // clones to nil, a non-empty one is copied. This mirrors what the live + // seam stores, so the differential oracle reasons about the same + // normalized deps. Key presence is preserved; the empty-vs-nil value + // distinction is intentionally collapsed, exactly as the seam collapses it. + out[k] = cloneDeps(v) + } + return out +} + +func cloneDirty(m map[string]struct{}) map[string]struct{} { + if m == nil { + return nil + } + out := make(map[string]struct{}, len(m)) + for k := range m { + out[k] = struct{}{} + } + return out +} + +func cloneU64Map(m map[string]uint64) map[string]uint64 { + if m == nil { + return nil + } + out := make(map[string]uint64, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +func cloneTimeMap(m map[string]time.Time) map[string]time.Time { + if m == nil { + return nil + } + out := make(map[string]time.Time, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +func cloneStoreState(st storeState) storeState { + return storeState{ + beads: cloneBeadMap(st.beads), + deps: cloneDepMap(st.deps), + depsComplete: st.depsComplete, + dirty: cloneDirty(st.dirty), + beadSeq: cloneU64Map(st.beadSeq), + localBeadAt: cloneTimeMap(st.localBeadAt), + deletedSeq: cloneU64Map(st.deletedSeq), + mutationSeq: st.mutationSeq, + backingIsBd: st.backingIsBd, + } +} + +func cloneSnapshotInputs(in snapshotInputs) snapshotInputs { + return snapshotInputs{ + freshByID: cloneBeadMap(in.freshByID), + confirmedClosed: cloneBeadMap(in.confirmedClosed), + depMap: cloneDepMap(in.depMap), + useFreshDeps: in.useFreshDeps, + startSeq: in.startSeq, + now: in.now, + } +} + +// --------------------------------------------------------------------------- +// Harness store construction + end-state capture +// --------------------------------------------------------------------------- + +// countingBacking wraps a Store and counts Get/List so the merge-purity +// assertion can prove the seam performs zero backing I/O. Every other Store +// method delegates through the embedded interface. +type countingBacking struct { + Store + inner Store + gets int + lists int +} + +func (b *countingBacking) Get(id string) (Bead, error) { + b.gets++ + return b.inner.Get(id) +} + +func (b *countingBacking) List(q ListQuery) ([]Bead, error) { + b.lists++ + return b.inner.List(q) +} + +// newMergeHarnessStore builds a CachingStore seeded directly from st. The +// backing is a call-counting fake for the off-BdStore case; for backingIsBd +// it is a nil *BdStore whose only in-seam use is depsForReconcileLocked's +// type assertion (no call), so a stray call would panic — a louder failure +// than a count mismatch. The store starts cacheLive (promoteLiveLocked +// overwrites it regardless). +func newMergeHarnessStore(st storeState) (*CachingStore, *countingBacking) { + var counter *countingBacking + var backing Store + if st.backingIsBd { + backing = (*BdStore)(nil) + } else { + backingTruth := NewMemStore() + counter = &countingBacking{Store: backingTruth, inner: backingTruth} + backing = counter + } + c := &CachingStore{ + backing: backing, + beads: cloneBeadMap(st.beads), + deps: cloneDepMap(st.deps), + depsComplete: st.depsComplete, + dirty: cloneDirty(st.dirty), + beadSeq: cloneU64Map(st.beadSeq), + localBeadAt: cloneTimeMap(st.localBeadAt), + deletedSeq: cloneU64Map(st.deletedSeq), + mutationSeq: st.mutationSeq, + state: cacheLive, + } + ensureMaps(c) + return c, counter +} + +// ensureMaps guarantees non-nil maps so seeded-empty states behave like a +// freshly constructed store. +func ensureMaps(c *CachingStore) { + if c.beads == nil { + c.beads = make(map[string]Bead) + } + if c.deps == nil { + c.deps = make(map[string][]Dep) + } + if c.dirty == nil { + c.dirty = make(map[string]struct{}) + } + if c.beadSeq == nil { + c.beadSeq = make(map[string]uint64) + } + if c.localBeadAt == nil { + c.localBeadAt = make(map[string]time.Time) + } + if c.deletedSeq == nil { + c.deletedSeq = make(map[string]uint64) + } +} + +func captureEndState(c *CachingStore) mergeEndState { + primeErr := "" + if c.primePartialErr != nil { + primeErr = c.primePartialErr.Error() + } + return mergeEndState{ + beads: cloneBeadMap(c.beads), + deps: cloneDepMap(c.deps), + depsComplete: c.depsComplete, + dirty: cloneDirty(c.dirty), + beadSeq: cloneU64Map(c.beadSeq), + localBeadAt: cloneTimeMap(c.localBeadAt), + deletedSeq: cloneU64Map(c.deletedSeq), + state: c.state, + lastFreshAt: c.lastFreshAt, + mutationSeq: c.mutationSeq, + primeErr: primeErr, + syncFailures: c.syncFailures, + statsAdds: c.stats.Adds, + statsRemoves: c.stats.Removes, + statsUpdates: c.stats.Updates, + statsLastReconcileAt: c.stats.LastReconcileAt, + statsLastFreshAt: c.stats.LastFreshAt, + } +} + +// --------------------------------------------------------------------------- +// The three implementations under test +// --------------------------------------------------------------------------- + +// mergeImpl runs one merge implementation against a store seeded from st with +// snapshot inputs in, and returns the captured end-state, notifications, +// counters, and backing-call counts. +type mergeImplResult struct { + end mergeEndState + notifications []cacheNotification + backingCalls int +} + +// runNewMerge exercises the LIVE collapsed seam (mergeSnapshotLocked). +func runNewMerge(st storeState, in snapshotInputs) mergeImplResult { + c, counter := newMergeHarnessStore(st) + c.mu.Lock() + res := c.mergeSnapshotLocked(in.freshByID, in.confirmedClosed, in.depMap, in.useFreshDeps, in.startSeq, in.now) + c.mu.Unlock() + return mergeImplResult{end: captureEndState(c), notifications: res.notifications, backingCalls: counterCalls(counter)} +} + +// runLegacyA exercises the frozen Branch A body. +func runLegacyA(st storeState, in snapshotInputs) mergeImplResult { + c, counter := newMergeHarnessStore(st) + c.mu.Lock() + res := legacyBranchAMerge(c, in.freshByID, in.confirmedClosed, in.depMap, in.useFreshDeps, in.startSeq, in.now) + c.mu.Unlock() + return mergeImplResult{end: captureEndState(c), notifications: res.notifications, backingCalls: counterCalls(counter)} +} + +// runLegacyB exercises the frozen Branch B body. +func runLegacyB(st storeState, in snapshotInputs) mergeImplResult { + c, counter := newMergeHarnessStore(st) + c.mu.Lock() + res := legacyBranchBMerge(c, in.freshByID, in.confirmedClosed, in.depMap, in.useFreshDeps, in.startSeq, in.now) + c.mu.Unlock() + return mergeImplResult{end: captureEndState(c), notifications: res.notifications, backingCalls: counterCalls(counter)} +} + +func counterCalls(counter *countingBacking) int { + if counter == nil { + return 0 + } + return counter.gets + counter.lists +} + +// --------------------------------------------------------------------------- +// FROZEN legacy Branch A — DO NOT EDIT (see provenance header above) +// --------------------------------------------------------------------------- + +// legacyBranchAMerge is the transliteration of the c.mutationSeq != startSeq +// arm of runReconciliation at 84c010a1b, minus the impure tail that stays in +// runReconciliation (backing.List, latency/cadence bookkeeping, the success +// log, notifyChanges). It performs the preserve pass, the per-row in-place +// absorb loop, and the eviction loop, then the tail scalars that the seam +// owns, and returns the notifications + counters. Caller holds c.mu. +func legacyBranchAMerge( + c *CachingStore, + freshByID map[string]Bead, confirmedClosed map[string]Bead, + depMap map[string][]Dep, useFreshDeps bool, + startSeq uint64, now time.Time, +) mergeSectionResult { + c.preserveCachedReadyProjectionLocked(freshByID, depMap, useFreshDeps) + + var adds, removes, updates int64 + notifications := make([]cacheNotification, 0, len(freshByID)) + nextDepsComplete := useFreshDeps + + for id, freshBead := range freshByID { + if c.deletedSeq[id] > startSeq || c.beadSeq[id] > startSeq { + if _, exists := c.beads[id]; exists { + if _, ok := c.deps[id]; !ok { + nextDepsComplete = false + } + } + continue + } + if _, keep := c.recentLocalBeadConflictLocked(id, freshBead, now, true); keep { + if _, ok := c.deps[id]; !ok { + nextDepsComplete = false + } + continue + } + freshDeps := c.depsForReconcileLocked(id, freshBead, depMap, useFreshDeps) + + old, exists := c.beads[id] + switch { + case !exists: + adds++ + notifications = append(notifications, cacheNotification{ + eventType: "bead.created", + bead: cloneBead(freshBead), + }) + case beadChanged(old, freshBead, true): + updates++ + notifications = append(notifications, cacheNotification{ + eventType: "bead.updated", + bead: cloneBead(freshBead), + }) + case depsChanged(c.deps[id], freshDeps): + updates++ + notifications = append(notifications, cacheNotification{ + eventType: "bead.updated", + bead: cloneBead(freshBead), + }) + } + + c.absorbFreshLocked(id, freshBead, now, absorbOpts{ + depsMode: depsExplicit, + deps: freshDeps, + seqMode: seqClearGuarded, + clearDirty: true, + }) + } + + for id, old := range c.beads { + if _, exists := freshByID[id]; exists { + continue + } + if c.deletedSeq[id] > startSeq || c.beadSeq[id] > startSeq { + continue + } + if old.Status != "closed" && recentLocalMutation(c.localBeadAt[id], now) { + continue + } + removes++ + if old.Status != "closed" { + closed := cloneBead(old) + closed.Status = "closed" + if freshClosed, ok := confirmedClosed[id]; ok { + closed = cloneBead(freshClosed) + } + notifications = append(notifications, cacheNotification{ + eventType: "bead.closed", + bead: closed, + }) + } + c.evictLocked(id) + } + + c.syncFailures = 0 + c.depsComplete = nextDepsComplete + c.primePartialErr = nil + c.promoteLiveLocked() + c.stats.LastReconcileAt = now + c.stats.Adds += adds + c.stats.Removes += removes + c.stats.Updates += updates + c.markFreshLocked(now) + return mergeSectionResult{notifications: notifications, adds: adds, removes: removes, updates: updates} +} + +// --------------------------------------------------------------------------- +// FROZEN legacy Branch B — DO NOT EDIT (see provenance header above) +// --------------------------------------------------------------------------- + +// legacyBranchBMerge is the transliteration of the quiescent (else) arm of +// runReconciliation at 84c010a1b: the whole-map rebuild. Same seam boundary +// as legacyBranchAMerge. Caller holds c.mu. +func legacyBranchBMerge( + c *CachingStore, + freshByID map[string]Bead, confirmedClosed map[string]Bead, + depMap map[string][]Dep, useFreshDeps bool, + _ uint64, now time.Time, +) mergeSectionResult { + c.preserveCachedReadyProjectionLocked(freshByID, depMap, useFreshDeps) + + var adds, removes, updates int64 + notifications := make([]cacheNotification, 0, len(freshByID)) + nextBeads := make(map[string]Bead, len(freshByID)) + nextDeps := make(map[string][]Dep, len(freshByID)) + nextDirty := make(map[string]struct{}) + nextBeadSeq := make(map[string]uint64) + nextLocalBeadAt := make(map[string]time.Time) + + for id, freshBead := range freshByID { + beadForCache := freshBead + preservedRecentLocal := false + if current, keep := c.recentLocalBeadConflictLocked(id, freshBead, now, true); keep { + beadForCache = current + preservedRecentLocal = true + c.carryRecentLocalMutationLocked(id, nextDirty, nextBeadSeq, nextLocalBeadAt) + } + freshDeps := c.depsForReconcileLocked(id, freshBead, depMap, useFreshDeps) + nextBeads[id] = cloneBead(beadForCache) + nextDeps[id] = cloneDeps(freshDeps) + + old, exists := c.beads[id] + switch { + case !exists: + adds++ + notifications = append(notifications, cacheNotification{ + eventType: "bead.created", + bead: cloneBead(beadForCache), + }) + case !preservedRecentLocal && beadChanged(old, freshBead, true): + updates++ + notifications = append(notifications, cacheNotification{ + eventType: "bead.updated", + bead: cloneBead(freshBead), + }) + case !preservedRecentLocal && depsChanged(c.deps[id], freshDeps): + updates++ + notifications = append(notifications, cacheNotification{ + eventType: "bead.updated", + bead: cloneBead(freshBead), + }) + } + } + + for id, old := range c.beads { + if _, exists := freshByID[id]; !exists { + if old.Status != "closed" && recentLocalMutation(c.localBeadAt[id], now) { + nextBeads[id] = cloneBead(old) + if deps, ok := c.deps[id]; ok { + nextDeps[id] = cloneDeps(deps) + } + c.carryRecentLocalMutationLocked(id, nextDirty, nextBeadSeq, nextLocalBeadAt) + continue + } + removes++ + if old.Status == "closed" { + continue + } + closed := cloneBead(old) + closed.Status = "closed" + if freshClosed, ok := confirmedClosed[id]; ok { + closed = cloneBead(freshClosed) + } + notifications = append(notifications, cacheNotification{ + eventType: "bead.closed", + bead: closed, + }) + } + } + + c.beads = nextBeads + c.deps = nextDeps + c.depsComplete = useFreshDeps + c.dirty = nextDirty + c.beadSeq = nextBeadSeq + c.localBeadAt = nextLocalBeadAt + c.deletedSeq = make(map[string]uint64) + c.syncFailures = 0 + c.primePartialErr = nil + c.promoteLiveLocked() + c.stats.LastReconcileAt = now + c.stats.Adds += adds + c.stats.Removes += removes + c.stats.Updates += updates + c.markFreshLocked(now) + return mergeSectionResult{notifications: notifications, adds: adds, removes: removes, updates: updates} +} + +// endStatesEqual is exact structural equality of two captured end-states, +// including nil-vs-empty distinctions for every map (reflect.DeepEqual treats +// nil and empty maps/slices as unequal, which is what depsComplete-degradation +// and entry-presence semantics require). time.Time values are exact copies of +// injected inputs across all runs, so DeepEqual compares them soundly. +func endStatesEqual(a, b mergeEndState) bool { + return reflect.DeepEqual(a, b) +} diff --git a/internal/beads/caching_store_reconcile_diffutil_test.go b/internal/beads/caching_store_reconcile_diffutil_test.go new file mode 100644 index 0000000000..bca1500df9 --- /dev/null +++ b/internal/beads/caching_store_reconcile_diffutil_test.go @@ -0,0 +1,180 @@ +package beads + +import ( + "fmt" + "reflect" + "sort" + "strings" + "time" +) + +func reflectDeepEqual(a, b any) bool { return reflect.DeepEqual(a, b) } + +// diffEndStates renders a human-readable field-by-field diff of two end-states +// for test failure output. +func diffEndStates(want, got mergeEndState) string { + var b strings.Builder + diffBeadMap(&b, "beads", want.beads, got.beads) + diffDepMap(&b, "deps", want.deps, got.deps) + diffStructSet(&b, "dirty", want.dirty, got.dirty) + diffU64Map(&b, "beadSeq", want.beadSeq, got.beadSeq) + diffTimeMap(&b, "localBeadAt", want.localBeadAt, got.localBeadAt) + diffU64Map(&b, "deletedSeq", want.deletedSeq, got.deletedSeq) + if want.depsComplete != got.depsComplete { + fmt.Fprintf(&b, " depsComplete: want=%v got=%v\n", want.depsComplete, got.depsComplete) + } + if want.state != got.state { + fmt.Fprintf(&b, " state: want=%v got=%v\n", want.state, got.state) + } + if !want.lastFreshAt.Equal(got.lastFreshAt) { + fmt.Fprintf(&b, " lastFreshAt: want=%v got=%v\n", want.lastFreshAt, got.lastFreshAt) + } + if want.mutationSeq != got.mutationSeq { + fmt.Fprintf(&b, " mutationSeq: want=%v got=%v\n", want.mutationSeq, got.mutationSeq) + } + if want.primeErr != got.primeErr { + fmt.Fprintf(&b, " primeErr: want=%q got=%q\n", want.primeErr, got.primeErr) + } + if want.syncFailures != got.syncFailures { + fmt.Fprintf(&b, " syncFailures: want=%v got=%v\n", want.syncFailures, got.syncFailures) + } + if want.statsAdds != got.statsAdds { + fmt.Fprintf(&b, " stats.Adds: want=%v got=%v\n", want.statsAdds, got.statsAdds) + } + if want.statsRemoves != got.statsRemoves { + fmt.Fprintf(&b, " stats.Removes: want=%v got=%v\n", want.statsRemoves, got.statsRemoves) + } + if want.statsUpdates != got.statsUpdates { + fmt.Fprintf(&b, " stats.Updates: want=%v got=%v\n", want.statsUpdates, got.statsUpdates) + } + if !want.statsLastReconcileAt.Equal(got.statsLastReconcileAt) { + fmt.Fprintf(&b, " stats.LastReconcileAt: want=%v got=%v\n", want.statsLastReconcileAt, got.statsLastReconcileAt) + } + if !want.statsLastFreshAt.Equal(got.statsLastFreshAt) { + fmt.Fprintf(&b, " stats.LastFreshAt: want=%v got=%v\n", want.statsLastFreshAt, got.statsLastFreshAt) + } + if b.Len() == 0 { + return " (no field-level diff detected — check reflect.DeepEqual edge cases)\n" + } + return b.String() +} + +func sortedKeysAny[V any](m map[string]V) []string { + ks := make([]string, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + sort.Strings(ks) + return ks +} + +func diffBeadMap(b *strings.Builder, label string, want, got map[string]Bead) { + keys := unionKeysBead(want, got) + for _, k := range keys { + wv, wok := want[k] + gv, gok := got[k] + switch { + case wok && !gok: + fmt.Fprintf(b, " %s[%q]: want present, got absent\n", label, k) + case !wok && gok: + fmt.Fprintf(b, " %s[%q]: want absent, got present\n", label, k) + case wok && gok && !reflect.DeepEqual(wv, gv): + fmt.Fprintf(b, " %s[%q]: bead differs\n want=%+v\n got =%+v\n", label, k, wv, gv) + } + } +} + +func diffDepMap(b *strings.Builder, label string, want, got map[string][]Dep) { + keys := unionKeysDep(want, got) + for _, k := range keys { + wv, wok := want[k] + gv, gok := got[k] + switch { + case wok && !gok: + fmt.Fprintf(b, " %s[%q]: want present (%v), got absent\n", label, k, wv) + case !wok && gok: + fmt.Fprintf(b, " %s[%q]: want absent, got present (%v)\n", label, k, gv) + case wok && gok && !reflect.DeepEqual(wv, gv): + fmt.Fprintf(b, " %s[%q]: want=%v got=%v\n", label, k, wv, gv) + } + } +} + +func diffStructSet(b *strings.Builder, label string, want, got map[string]struct{}) { + for _, k := range sortedKeysAny(want) { + if _, ok := got[k]; !ok { + fmt.Fprintf(b, " %s[%q]: want present, got absent\n", label, k) + } + } + for _, k := range sortedKeysAny(got) { + if _, ok := want[k]; !ok { + fmt.Fprintf(b, " %s[%q]: want absent, got present\n", label, k) + } + } +} + +func diffU64Map(b *strings.Builder, label string, want, got map[string]uint64) { + keys := unionKeysU64(want, got) + for _, k := range keys { + wv, wok := want[k] + gv, gok := got[k] + if wok != gok || wv != gv { + fmt.Fprintf(b, " %s[%q]: want=(%d,present=%v) got=(%d,present=%v)\n", label, k, wv, wok, gv, gok) + } + } +} + +func diffTimeMap(b *strings.Builder, label string, want, got map[string]time.Time) { + keys := unionKeysTime(want, got) + for _, k := range keys { + wv, wok := want[k] + gv, gok := got[k] + if wok != gok || !wv.Equal(gv) { + fmt.Fprintf(b, " %s[%q]: want=(%v,present=%v) got=(%v,present=%v)\n", label, k, wv, wok, gv, gok) + } + } +} + +func unionKeysBead(a, b map[string]Bead) []string { + set := map[string]struct{}{} + for k := range a { + set[k] = struct{}{} + } + for k := range b { + set[k] = struct{}{} + } + return sortedKeysAny(set) +} + +func unionKeysDep(a, b map[string][]Dep) []string { + set := map[string]struct{}{} + for k := range a { + set[k] = struct{}{} + } + for k := range b { + set[k] = struct{}{} + } + return sortedKeysAny(set) +} + +func unionKeysU64(a, b map[string]uint64) []string { + set := map[string]struct{}{} + for k := range a { + set[k] = struct{}{} + } + for k := range b { + set[k] = struct{}{} + } + return sortedKeysAny(set) +} + +func unionKeysTime(a, b map[string]time.Time) []string { + set := map[string]struct{}{} + for k := range a { + set[k] = struct{}{} + } + for k := range b { + set[k] = struct{}{} + } + return sortedKeysAny(set) +} diff --git a/internal/beads/caching_store_reconcile_fixtures_test.go b/internal/beads/caching_store_reconcile_fixtures_test.go new file mode 100644 index 0000000000..c7d92a0cf3 --- /dev/null +++ b/internal/beads/caching_store_reconcile_fixtures_test.go @@ -0,0 +1,462 @@ +package beads + +import ( + "testing" + "time" +) + +// Fixed reference clock for all fixtures; recency offsets are relative to it. +var fxNow = time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC) + +func fxRecent() time.Time { return fxNow.Add(-2 * time.Second) } // well inside window +func fxBoundary() time.Time { return fxNow.Add(-5 * time.Second) } // exactly 5s → still recent +func fxJustOver() time.Time { return fxNow.Add(-5001 * time.Millisecond) } // 5.001s → stale +func fxStale() time.Time { return fxNow.Add(-time.Hour) } // far stale + +func bead(id, status string) Bead { + return Bead{ID: id, Title: id, Status: status, Type: "task", CreatedAt: fxNow} +} + +func beadWith(id, status string, mut func(*Bead)) Bead { + b := bead(id, status) + mut(&b) + return b +} + +func dep(issue, dependsOn string) Dep { + return Dep{IssueID: issue, DependsOnID: dependsOn, Type: "blocks"} +} + +type mergeFixture struct { + name string + st storeState + in snapshotInputs +} + +// mergeFixtures enumerates the §1.4 cells (B1-B11), the §2 deltas, and the +// bug-lineage regression shapes. Both regimes are represented. +func mergeFixtures() []mergeFixture { + var fx []mergeFixture + + // Regime scaffolding: quiescent has mutationSeq==startSeq and all fences + // <= startSeq; mutated has mutationSeq>startSeq and may fence > startSeq. + const qseq = uint64(100) + const mseq = uint64(200) + quiIn := func() snapshotInputs { + return snapshotInputs{startSeq: qseq, now: fxNow, useFreshDeps: true} + } + mutIn := func() snapshotInputs { + return snapshotInputs{startSeq: qseq, now: fxNow, useFreshDeps: true} + } + _ = mutIn + + // --- B1: quiescent, in snapshot ∧ cached ∧ not recent ∧ changed → absorb --- + { + in := quiIn() + in.freshByID = map[string]Bead{"a": beadWith("a", "open", func(b *Bead) { b.Title = "new" })} + in.depMap = map[string][]Dep{"a": {dep("a", "x")}} + fx = append(fx, mergeFixture{ + name: "B1_absorb_changed", + st: storeState{ + beads: map[string]Bead{"a": bead("a", "open")}, + deps: map[string][]Dep{"a": {dep("a", "y")}}, + mutationSeq: qseq, + }, + in: in, + }) + } + + // --- B2/D5: quiescent absorb, recent, NOT beadChanged → NEW keeps fences --- + { + in := quiIn() + in.freshByID = map[string]Bead{"a": bead("a", "open")} + in.depMap = map[string][]Dep{"a": {dep("a", "x")}} + fx = append(fx, mergeFixture{ + name: "B2_D5_recent_no_conflict", + st: storeState{ + beads: map[string]Bead{"a": bead("a", "open")}, + deps: map[string][]Dep{"a": {dep("a", "x")}}, + beadSeq: map[string]uint64{"a": 90}, + localBeadAt: map[string]time.Time{"a": fxRecent()}, + mutationSeq: qseq, + }, + in: in, + }) + } + + // --- B3/D4/D2: quiescent recency-keep, cached deps present → keep cached deps --- + { + in := quiIn() + in.freshByID = map[string]Bead{"a": beadWith("a", "closed", func(_ *Bead) {})} // status change ⇒ beadChanged + in.depMap = map[string][]Dep{"a": {dep("a", "fresh")}} + fx = append(fx, mergeFixture{ + name: "B3_D4_recency_keep_with_cached_deps", + st: storeState{ + beads: map[string]Bead{"a": bead("a", "open")}, + deps: map[string][]Dep{"a": {dep("a", "cached")}}, + beadSeq: map[string]uint64{"a": 90}, + localBeadAt: map[string]time.Time{"a": fxRecent()}, + mutationSeq: qseq, + }, + in: in, + }) + } + + // --- B3/D2: quiescent recency-keep, NO cached deps entry → depsComplete flip --- + { + in := quiIn() + in.freshByID = map[string]Bead{"a": bead("a", "closed")} + in.depMap = map[string][]Dep{"a": {dep("a", "fresh")}} + fx = append(fx, mergeFixture{ + name: "B3_D2_recency_keep_no_cached_deps", + st: storeState{ + beads: map[string]Bead{"a": bead("a", "open")}, + deps: map[string][]Dep{}, // no entry for a + beadSeq: map[string]uint64{"a": 90}, + localBeadAt: map[string]time.Time{"a": fxRecent()}, + mutationSeq: qseq, + }, + in: in, + }) + } + + // --- B4: quiescent, in snapshot ∧ NOT cached → created --- + { + in := quiIn() + in.freshByID = map[string]Bead{"a": bead("a", "open")} + in.depMap = map[string][]Dep{"a": {dep("a", "x")}} + fx = append(fx, mergeFixture{ + name: "B4_created", + st: storeState{ + beads: map[string]Bead{}, + deps: map[string][]Dep{}, + mutationSeq: qseq, + }, + in: in, + }) + } + + // --- B4-orphan/D5: created over a stale orphan fence, recent → keep fences --- + { + in := quiIn() + in.freshByID = map[string]Bead{"a": bead("a", "open")} + in.depMap = map[string][]Dep{"a": {dep("a", "x")}} + fx = append(fx, mergeFixture{ + name: "B4orphan_D5_recent_orphan_now_in_snapshot", + st: storeState{ + beads: map[string]Bead{}, + deps: map[string][]Dep{}, + beadSeq: map[string]uint64{"a": 88}, + localBeadAt: map[string]time.Time{"a": fxRecent()}, + mutationSeq: qseq, + }, + in: in, + }) + } + + // --- B5: quiescent, missing ∧ cached ∧ non-closed ∧ recent → carry (skip) --- + { + in := quiIn() + in.freshByID = map[string]Bead{} + fx = append(fx, mergeFixture{ + name: "B5_evict_recency_keep", + st: storeState{ + beads: map[string]Bead{"a": bead("a", "open")}, + deps: map[string][]Dep{"a": {dep("a", "x")}}, + beadSeq: map[string]uint64{"a": 90}, + localBeadAt: map[string]time.Time{"a": fxRecent()}, + mutationSeq: qseq, + }, + in: in, + }) + } + + // --- B6: quiescent, missing ∧ cached ∧ closed → evict, no notification --- + { + in := quiIn() + in.freshByID = map[string]Bead{} + fx = append(fx, mergeFixture{ + name: "B6_evict_closed", + st: storeState{ + beads: map[string]Bead{"a": bead("a", "closed")}, + deps: map[string][]Dep{"a": {dep("a", "x")}}, + mutationSeq: qseq, + }, + in: in, + }) + } + + // --- B7: quiescent, missing ∧ cached ∧ non-closed ∧ stale → evict + closed --- + { + in := quiIn() + in.freshByID = map[string]Bead{} + in.confirmedClosed = map[string]Bead{"a": beadWith("a", "closed", func(b *Bead) { b.Title = "auth" })} + fx = append(fx, mergeFixture{ + name: "B7_evict_confirmed_closed", + st: storeState{ + beads: map[string]Bead{"a": bead("a", "open")}, + deps: map[string][]Dep{"a": {dep("a", "x")}}, + localBeadAt: map[string]time.Time{"a": fxStale()}, + mutationSeq: qseq, + }, + in: in, + }) + } + + // --- B8/D3': quiescent orphan fences, recent → keep --- + { + in := quiIn() + in.freshByID = map[string]Bead{} + fx = append(fx, mergeFixture{ + name: "B8_D3prime_orphan_recent", + st: storeState{ + beads: map[string]Bead{}, + dirty: map[string]struct{}{"a": {}}, + beadSeq: map[string]uint64{"a": 90}, + localBeadAt: map[string]time.Time{"a": fxRecent()}, + mutationSeq: qseq, + }, + in: in, + }) + } + + // --- B8: quiescent orphan fences, stale → GC'd (≡ B) --- + { + in := quiIn() + in.freshByID = map[string]Bead{} + fx = append(fx, mergeFixture{ + name: "B8_orphan_stale_gc", + st: storeState{ + beads: map[string]Bead{}, + dirty: map[string]struct{}{"a": {}}, + beadSeq: map[string]uint64{"a": 90}, + localBeadAt: map[string]time.Time{"a": fxStale()}, + mutationSeq: qseq, + }, + in: in, + }) + } + + // --- B9/D1': quiescent tombstone with recent localAt → keep-all --- + { + in := quiIn() + in.freshByID = map[string]Bead{} + fx = append(fx, mergeFixture{ + name: "B9_D1prime_tombstone_recent", + st: storeState{ + beads: map[string]Bead{}, + deletedSeq: map[string]uint64{"a": 95}, + localBeadAt: map[string]time.Time{"a": fxRecent()}, + mutationSeq: qseq, + }, + in: in, + }) + } + + // --- B9: quiescent tombstone, stale → GC'd (≡ B wholesale wipe) --- + { + in := quiIn() + in.freshByID = map[string]Bead{} + fx = append(fx, mergeFixture{ + name: "B9_tombstone_stale_gc", + st: storeState{ + beads: map[string]Bead{}, + deletedSeq: map[string]uint64{"a": 95}, + mutationSeq: qseq, + }, + in: in, + }) + } + + // --- Orphan deps-only, recent → kept (D3' deps family, council delete-B gap) --- + { + in := quiIn() + in.freshByID = map[string]Bead{} + fx = append(fx, mergeFixture{ + name: "orphan_deps_only_recent_kept", + st: storeState{ + beads: map[string]Bead{}, + deps: map[string][]Dep{"a": {dep("a", "x")}}, + localBeadAt: map[string]time.Time{"a": fxRecent()}, + mutationSeq: qseq, + }, + in: in, + }) + } + + // --- Orphan deps-only, stale → GC'd (immortal-deps regression guard) --- + { + in := quiIn() + in.freshByID = map[string]Bead{} + fx = append(fx, mergeFixture{ + name: "orphan_deps_only_stale_gc", + st: storeState{ + beads: map[string]Bead{}, + deps: map[string][]Dep{"a": {dep("a", "x")}}, + mutationSeq: qseq, + }, + in: in, + }) + } + + // --- MUTATED / D1: tombstone unprotected orphan the sweep collects (vs A) --- + { + in := snapshotInputs{startSeq: qseq, now: fxNow, useFreshDeps: true} + in.freshByID = map[string]Bead{"a": bead("a", "open")} + in.depMap = map[string][]Dep{"a": {dep("a", "x")}} + fx = append(fx, mergeFixture{ + name: "D1_mutated_tombstone_orphan_gc", + st: storeState{ + beads: map[string]Bead{"a": bead("a", "open")}, + deps: map[string][]Dep{"a": {dep("a", "x")}}, + deletedSeq: map[string]uint64{"gone": 95}, // orphan tombstone, seq<=startSeq + mutationSeq: mseq, // mutated regime + }, + in: in, + }) + } + + // --- MUTATED / D3: orphan dirty/beadSeq leaked, sweep collects (vs A) --- + { + in := snapshotInputs{startSeq: qseq, now: fxNow, useFreshDeps: true} + in.freshByID = map[string]Bead{"a": bead("a", "open")} + in.depMap = map[string][]Dep{"a": {dep("a", "x")}} + fx = append(fx, mergeFixture{ + name: "D3_mutated_orphan_fences_gc", + st: storeState{ + beads: map[string]Bead{"a": bead("a", "open")}, + deps: map[string][]Dep{"a": {dep("a", "x")}}, + dirty: map[string]struct{}{"gone": {}}, + beadSeq: map[string]uint64{"gone": 80}, + mutationSeq: mseq, + }, + in: in, + }) + } + + // --- MUTATED / fence arm: beadSeq > startSeq keeps the row (skipFenced) --- + { + in := snapshotInputs{startSeq: qseq, now: fxNow, useFreshDeps: true} + in.freshByID = map[string]Bead{"a": beadWith("a", "open", func(b *Bead) { b.Title = "stale-scan" })} + in.depMap = map[string][]Dep{"a": {dep("a", "x")}} + fx = append(fx, mergeFixture{ + name: "mutated_beadSeq_fence_skip", + st: storeState{ + beads: map[string]Bead{"a": beadWith("a", "open", func(b *Bead) { b.Title = "local-write" })}, + deps: map[string][]Dep{"a": {dep("a", "x")}}, + beadSeq: map[string]uint64{"a": 150}, // > startSeq + localBeadAt: map[string]time.Time{"a": fxRecent()}, + mutationSeq: mseq, + }, + in: in, + }) + } + + // --- MUTATED / tombstone fence: deletedSeq > startSeq keeps eviction skip --- + { + in := snapshotInputs{startSeq: qseq, now: fxNow, useFreshDeps: true} + in.freshByID = map[string]Bead{"a": bead("a", "open")} + in.depMap = map[string][]Dep{"a": {dep("a", "x")}} + fx = append(fx, mergeFixture{ + name: "mutated_tombstone_fence_absorb_skip", + st: storeState{ + beads: map[string]Bead{}, + deletedSeq: map[string]uint64{"a": 150}, // delete raced the scan + mutationSeq: mseq, + }, + in: in, + }) + } + + // --- #2210 shape: local DepAdd inside window, snapshot lags (recency keep) --- + { + in := quiIn() + in.freshByID = map[string]Bead{"a": beadWith("a", "in_progress", func(_ *Bead) {})} + in.depMap = map[string][]Dep{"a": {}} // snapshot dropped the just-added dep + fx = append(fx, mergeFixture{ + name: "reg_2210_local_depadd_in_window", + st: storeState{ + beads: map[string]Bead{"a": bead("a", "open")}, + deps: map[string][]Dep{"a": {dep("a", "just-added")}}, + beadSeq: map[string]uint64{"a": 90}, + localBeadAt: map[string]time.Time{"a": fxRecent()}, + mutationSeq: qseq, + }, + in: in, + }) + } + + // --- nil-vs-empty deps classification: must NOT emit bead.updated --- + { + in := quiIn() + in.useFreshDeps = true + in.freshByID = map[string]Bead{"a": bead("a", "open")} + in.depMap = map[string][]Dep{} // fresh deps nil + fx = append(fx, mergeFixture{ + name: "reg_nil_vs_empty_deps", + st: storeState{ + beads: map[string]Bead{"a": bead("a", "open")}, + deps: map[string][]Dep{"a": {}}, // cached empty (non-nil) + mutationSeq: qseq, + }, + in: in, + }) + } + + // --- boundary recency 5.000s: still recent (keeps) --- + { + in := quiIn() + in.freshByID = map[string]Bead{"a": beadWith("a", "closed", func(_ *Bead) {})} + in.depMap = map[string][]Dep{"a": {dep("a", "fresh")}} + fx = append(fx, mergeFixture{ + name: "boundary_recency_5000ms_recent", + st: storeState{ + beads: map[string]Bead{"a": bead("a", "open")}, + deps: map[string][]Dep{"a": {dep("a", "cached")}}, + beadSeq: map[string]uint64{"a": 90}, + localBeadAt: map[string]time.Time{"a": fxBoundary()}, + mutationSeq: qseq, + }, + in: in, + }) + } + + // --- boundary recency 5.001s: stale (absorbs) --- + { + in := quiIn() + in.freshByID = map[string]Bead{"a": beadWith("a", "closed", func(_ *Bead) {})} + in.depMap = map[string][]Dep{"a": {dep("a", "fresh")}} + fx = append(fx, mergeFixture{ + name: "boundary_recency_5001ms_stale", + st: storeState{ + beads: map[string]Bead{"a": bead("a", "open")}, + deps: map[string][]Dep{"a": {dep("a", "cached")}}, + beadSeq: map[string]uint64{"a": 90}, + localBeadAt: map[string]time.Time{"a": fxJustOver()}, + mutationSeq: qseq, + }, + in: in, + }) + } + + return fx +} + +func TestReconcileMergeDifferential_Fixtures(t *testing.T) { + for _, f := range mergeFixtures() { + f := f + t.Run(f.name, func(t *testing.T) { + // Run both backing variants so the depsForReconcileLocked fallback + // is exercised on the same shapes. + for _, bd := range []bool{false, true} { + st := cloneStoreState(f.st) + st.backingIsBd = bd + variant := "memBacking" + if bd { + variant = "bdBacking" + } + assertDifferential(t, f.name+"/"+variant, st, cloneSnapshotInputs(f.in)) + } + }) + } +} diff --git a/internal/beads/caching_store_reconcile_generators_test.go b/internal/beads/caching_store_reconcile_generators_test.go new file mode 100644 index 0000000000..3a72c5cce6 --- /dev/null +++ b/internal/beads/caching_store_reconcile_generators_test.go @@ -0,0 +1,637 @@ +package beads + +import ( + "fmt" + "math/rand" + "testing" + "time" +) + +// --------------------------------------------------------------------------- +// Tier 1: exhaustive guard-axis grid (single source of truth for the cross) +// --------------------------------------------------------------------------- + +type guardCellSpec struct { + regime string + presence string + tomb string + seq string + recency string + changed string // "na" outside presence=both + cachedStatus string + freshStatus string +} + +var ( + changedVals = []string{"equal", "status", "labels", "isblocked", "metadata", "needs", "depsfield"} + cachedStatusVals = []string{"open", "in_progress", "closed"} + freshStatusVals = []string{"open", "closed"} + recencyVals = []string{"none", "recent", "boundary", "justover", "stale"} +) + +func fenceValsFor(regime string) []string { + if regime == "mutated" { + return []string{"none", "lt", "eq", "gt"} + } + return []string{"none", "lt", "eq"} +} + +// forEachGuardCell drives BOTH enumeration and generation so the intended +// cross and the generated corpus can never drift. +func forEachGuardCell(fn func(spec guardCellSpec)) { + for _, regime := range []string{"quiescent", "mutated"} { + fences := fenceValsFor(regime) + for _, seq := range fences { + for _, rec := range recencyVals { + // presence = both (cached ⇒ tomb=none by V1) + for _, cs := range cachedStatusVals { + for _, ch := range changedVals { + fn(guardCellSpec{regime, "both", "none", seq, rec, ch, cs, derivedFreshStatus(cs, ch)}) + } + } + // presence = cache (cached ⇒ tomb=none) + for _, cs := range cachedStatusVals { + fn(guardCellSpec{regime, "cache", "none", seq, rec, "na", cs, "na"}) + } + // presence = snap (no cached bead ⇒ tomb may be set) + for _, tomb := range fences { + for _, fs := range freshStatusVals { + fn(guardCellSpec{regime, "snap", tomb, seq, rec, "na", "na", fs}) + } + // presence = neither (orphan fences/deps only) + fn(guardCellSpec{regime, "neither", tomb, seq, rec, "na", "na", "na"}) + } + } + } + } +} + +func derivedFreshStatus(cached, changed string) string { + if changed == "status" { + if cached == "closed" { + return "open" + } + return "closed" + } + return cached +} + +func fenceValue(cell string) uint64 { + switch cell { + case "lt": + return 90 + case "eq": + return 100 + case "gt": + return 150 + default: + return 0 + } +} + +func recencyValue(cell string) (time.Time, bool) { + switch cell { + case "recent": + return fxRecent(), true + case "boundary": + return fxBoundary(), true + case "justover": + return fxJustOver(), true + case "stale": + return fxStale(), true + default: + return time.Time{}, false + } +} + +// buildGuardState materializes a single-row state for id "a" from a spec. +func buildGuardState(spec guardCellSpec) (storeState, snapshotInputs, string) { + const startSeq = uint64(100) + st := storeState{ + beads: map[string]Bead{}, + deps: map[string][]Dep{}, + dirty: map[string]struct{}{}, + beadSeq: map[string]uint64{}, + localBeadAt: map[string]time.Time{}, + deletedSeq: map[string]uint64{}, + } + in := snapshotInputs{ + freshByID: map[string]Bead{}, + depMap: map[string][]Dep{}, + useFreshDeps: true, + startSeq: startSeq, + now: fxNow, + } + if spec.regime == "mutated" { + st.mutationSeq = 200 + } else { + st.mutationSeq = startSeq + } + const id = "a" + + cachedPresent := spec.presence == "both" || spec.presence == "cache" + freshPresent := spec.presence == "both" || spec.presence == "snap" + + var cached Bead + if cachedPresent { + cached = bead(id, spec.cachedStatus) + st.beads[id] = cached + st.deps[id] = []Dep{dep(id, "cacheddep")} + } + if freshPresent { + var fresh Bead + if spec.presence == "both" { + fresh = deriveFresh(cached, spec.changed) + } else { + fresh = bead(id, spec.freshStatus) + } + in.freshByID[id] = fresh + in.depMap[id] = []Dep{dep(id, "cacheddep")} // equal to cached ⇒ no depsChanged noise + } + + // Fences / recency / tombstone. + if v := fenceValue(spec.seq); v != 0 { + st.beadSeq[id] = v + } + if !cachedPresent { // V1: no tombstone alongside a live row + if v := fenceValue(spec.tomb); v != 0 { + st.deletedSeq[id] = v + } + } + if t, ok := recencyValue(spec.recency); ok { + st.localBeadAt[id] = t + } + + name := fmt.Sprintf("%s_%s_tomb-%s_seq-%s_rec-%s_ch-%s_cs-%s_fs-%s", + spec.regime, spec.presence, spec.tomb, spec.seq, spec.recency, spec.changed, spec.cachedStatus, spec.freshStatus) + return st, in, name +} + +func deriveFresh(cached Bead, changed string) Bead { + f := cloneBead(cached) + switch changed { + case "status": + if cached.Status == "closed" { + f.Status = "open" + } else { + f.Status = "closed" + } + case "labels": + f.Labels = []string{"L1"} + case "isblocked": + v := true + f.IsBlocked = &v + case "metadata": + f.Metadata = StringMap{"k": "v"} + case "needs": + f.Needs = []string{"n1"} + case "depsfield": + f.Dependencies = []Dep{dep(cached.ID, "d1")} + case "equal": + // identical + } + return f +} + +func genGridStates() []mergeFixture { + var out []mergeFixture + forEachGuardCell(func(spec guardCellSpec) { + st, in, name := buildGuardState(spec) + out = append(out, mergeFixture{name: name, st: st, in: in}) + }) + return out +} + +// --------------------------------------------------------------------------- +// Marginal generator: guarantees each soft/hardened axis value is observed +// --------------------------------------------------------------------------- + +func genMarginalStates() []mergeFixture { + var out []mergeFixture + const q = uint64(100) + base := func() (storeState, snapshotInputs) { + return storeState{ + beads: map[string]Bead{}, deps: map[string][]Dep{}, dirty: map[string]struct{}{}, + beadSeq: map[string]uint64{}, localBeadAt: map[string]time.Time{}, deletedSeq: map[string]uint64{}, + mutationSeq: q, + }, + snapshotInputs{freshByID: map[string]Bead{}, depMap: map[string][]Dep{}, useFreshDeps: true, startSeq: q, now: fxNow} + } + + // dirty=true + { + st, in := base() + st.beads["a"] = bead("a", "open") + st.deps["a"] = []Dep{dep("a", "x")} + st.dirty["a"] = struct{}{} + in.freshByID["a"] = beadWith("a", "open", func(b *Bead) { b.Title = "chg" }) + in.depMap["a"] = []Dep{dep("a", "x")} + out = append(out, mergeFixture{"marg_dirty_true", st, in}) + } + // depMapCell nil / empty / nonempty (useFreshDeps=true) + for _, dc := range []string{"nil", "empty", "nonempty"} { + st, in := base() + st.beads["a"] = bead("a", "open") + st.deps["a"] = []Dep{dep("a", "x")} + in.freshByID["a"] = bead("a", "open") + switch dc { + case "nil": + in.depMap["a"] = nil + in.depMap = map[string][]Dep{"a": nil} + case "empty": + in.depMap["a"] = []Dep{} + case "nonempty": + in.depMap["a"] = []Dep{dep("a", "x")} + } + out = append(out, mergeFixture{"marg_depMap_" + dc, st, in}) + } + // fieldDeps none/needs/deps/both (useFreshDeps=false) + for _, fd := range []string{"none", "needs", "deps", "both"} { + st, in := base() + in.useFreshDeps = false + st.beads["a"] = bead("a", "open") + fresh := bead("a", "open") + switch fd { + case "needs": + fresh.Needs = []string{"n1"} + case "deps": + fresh.Dependencies = []Dep{dep("a", "d1")} + case "both": + fresh.Needs = []string{"n1"} + fresh.Dependencies = []Dep{dep("a", "d1")} + } + in.freshByID["a"] = fresh + out = append(out, mergeFixture{"marg_fieldDeps_" + fd, st, in}) + } + // cachedDeps absent/nil/empty/nonempty + for _, cd := range []string{"absent", "nil", "empty", "nonempty"} { + st, in := base() + st.beads["a"] = bead("a", "open") + switch cd { + case "nil": + st.deps = map[string][]Dep{"a": nil} + case "empty": + st.deps["a"] = []Dep{} + case "nonempty": + st.deps["a"] = []Dep{dep("a", "x")} + } + in.freshByID["a"] = beadWith("a", "open", func(b *Bead) { b.Title = "chg" }) + in.depMap["a"] = []Dep{dep("a", "x")} + out = append(out, mergeFixture{"marg_cachedDeps_" + cd, st, in}) + } + // confirmedClosed=true (cache-only non-closed stale eviction) + { + st, in := base() + st.beads["a"] = bead("a", "open") + st.localBeadAt["a"] = fxStale() + in.confirmedClosed = map[string]Bead{"a": beadWith("a", "closed", func(b *Bead) { b.Title = "auth" })} + out = append(out, mergeFixture{"marg_confirmedClosed", st, in}) + } + // recency "now" (zero elapsed) — council boundary cell + { + st, in := base() + st.beads["a"] = bead("a", "open") + st.localBeadAt["a"] = fxNow + in.freshByID["a"] = beadWith("a", "closed", func(_ *Bead) {}) + in.depMap["a"] = []Dep{dep("a", "x")} + out = append(out, mergeFixture{"marg_recency_now", st, in}) + } + // preserveOutcome: inapplicable / no-cached / applied / blocked-deps / blocked-target + // applied: fresh IsBlocked nil, cached IsBlocked set, deps unchanged, no target flip + mkPreserve := func(name string, setup func(st *storeState, in *snapshotInputs)) { + st, in := base() + setup(&st, &in) + out = append(out, mergeFixture{"marg_preserve_" + name, st, in}) + } + tb := true + mkPreserve("inapplicable", func(st *storeState, in *snapshotInputs) { + st.beads["a"] = beadWith("a", "open", func(b *Bead) { b.IsBlocked = &tb }) + st.deps["a"] = []Dep{dep("a", "x")} + fresh := beadWith("a", "open", func(b *Bead) { v := false; b.IsBlocked = &v }) + in.freshByID["a"] = fresh + in.depMap["a"] = []Dep{dep("a", "x")} + }) + mkPreserve("no-cached", func(st *storeState, in *snapshotInputs) { + st.beads["a"] = bead("a", "open") // cached IsBlocked nil + st.deps["a"] = []Dep{dep("a", "x")} + in.freshByID["a"] = bead("a", "open") // fresh IsBlocked nil + in.depMap["a"] = []Dep{dep("a", "x")} + }) + mkPreserve("applied", func(st *storeState, in *snapshotInputs) { + st.beads["a"] = beadWith("a", "open", func(b *Bead) { b.IsBlocked = &tb }) + st.deps["a"] = []Dep{dep("a", "x")} + in.freshByID["a"] = bead("a", "open") // fresh IsBlocked nil + in.depMap["a"] = []Dep{dep("a", "x")} + }) + mkPreserve("blocked-deps", func(st *storeState, in *snapshotInputs) { + st.beads["a"] = beadWith("a", "open", func(b *Bead) { b.IsBlocked = &tb }) + st.deps["a"] = []Dep{dep("a", "x")} + in.freshByID["a"] = bead("a", "open") + in.depMap["a"] = []Dep{dep("a", "different")} // deps changed ⇒ preserve blocked + }) + mkPreserve("blocked-target", func(st *storeState, in *snapshotInputs) { + st.beads["a"] = beadWith("a", "open", func(b *Bead) { b.IsBlocked = &tb }) + st.deps["a"] = []Dep{{IssueID: "a", DependsOnID: "t", Type: "blocks"}} + st.beads["t"] = bead("t", "open") + in.freshByID["a"] = bead("a", "open") + in.freshByID["t"] = bead("t", "closed") // target status flipped ⇒ preserve blocked + in.depMap["a"] = []Dep{{IssueID: "a", DependsOnID: "t", Type: "blocks"}} + in.depMap["t"] = nil + }) + return out +} + +// --------------------------------------------------------------------------- +// Tier 2: seeded pseudo-random multi-row states +// --------------------------------------------------------------------------- + +func genSeededStates(seed int64, count int) []mergeFixture { + rng := rand.New(rand.NewSource(seed)) + out := make([]mergeFixture, 0, count) + for i := 0; i < count; i++ { + out = append(out, genRandomState(rng, i)) + } + return out +} + +func genRandomState(rng *rand.Rand, idx int) mergeFixture { + const startSeq = uint64(100) + mutated := rng.Intn(2) == 0 + st := storeState{ + beads: map[string]Bead{}, deps: map[string][]Dep{}, dirty: map[string]struct{}{}, + beadSeq: map[string]uint64{}, localBeadAt: map[string]time.Time{}, deletedSeq: map[string]uint64{}, + backingIsBd: rng.Intn(2) == 0, + } + in := snapshotInputs{ + freshByID: map[string]Bead{}, confirmedClosed: map[string]Bead{}, depMap: map[string][]Dep{}, + useFreshDeps: rng.Intn(2) == 0, startSeq: startSeq, now: fxNow, + } + if mutated { + st.mutationSeq = startSeq + uint64(1+rng.Intn(100)) + } else { + st.mutationSeq = startSeq + } + + nRows := 1 + rng.Intn(6) + statuses := []string{"open", "in_progress", "closed"} + recencies := []string{"none", "recent", "boundary", "justover", "stale", "now"} + for r := 0; r < nRows; r++ { + id := fmt.Sprintf("r%d", r) + presence := rng.Intn(4) // 0 both,1 snap,2 cache,3 neither + cachedPresent := presence == 0 || presence == 2 + freshPresent := presence == 0 || presence == 1 + + if cachedPresent { + cb := bead(id, statuses[rng.Intn(3)]) + applyRandomFields(rng, &cb) + st.beads[id] = cb + if rng.Intn(3) != 0 { + st.deps[id] = randDeps(rng, id) + } + } + if freshPresent { + fb := bead(id, statuses[rng.Intn(3)]) + applyRandomFields(rng, &fb) + in.freshByID[id] = fb + if in.useFreshDeps && rng.Intn(3) != 0 { + in.depMap[id] = randDeps(rng, id) + } + if cachedPresent && !isClosed(st.beads[id]) && rng.Intn(4) == 0 { + in.confirmedClosed[id] = beadWith(id, "closed", func(_ *Bead) {}) + } + } + // Fences (respect V: V4 seq <= mutationSeq; quiescent ⇒ <= startSeq + // since mutationSeq==startSeq; tombstone ⇒ no live row). + if rng.Intn(2) == 0 { + st.beadSeq[id] = randFence(rng, startSeq, st.mutationSeq) + } + if !cachedPresent && rng.Intn(2) == 0 { + st.deletedSeq[id] = randFence(rng, startSeq, st.mutationSeq) + } + if rng.Intn(2) == 0 { + if t, ok := recencyValue2(recencies[rng.Intn(len(recencies))]); ok { + st.localBeadAt[id] = t + } + } + if rng.Intn(3) == 0 { + st.dirty[id] = struct{}{} + } + // Orphan deps-only entries for a never-present id occasionally. + if presence == 3 && rng.Intn(2) == 0 { + st.deps[id] = randDeps(rng, id) + } + } + return mergeFixture{name: fmt.Sprintf("seed%d_case%d", 0, idx), st: st, in: in} +} + +func recencyValue2(cell string) (time.Time, bool) { + if cell == "now" { + return fxNow, true + } + return recencyValue(cell) +} + +// randFence returns a fence value in [1, mutationSeq] (V4). When mutationSeq > +// startSeq (mutated regime) it biases toward the (startSeq, mutationSeq] band so +// the > startSeq fence arms are exercised; it never exceeds mutationSeq, so a +// quiescent state (mutationSeq==startSeq) automatically satisfies invariant Q. +func randFence(rng *rand.Rand, startSeq, mutationSeq uint64) uint64 { + if mutationSeq > startSeq && rng.Intn(2) == 0 { + return startSeq + 1 + uint64(rng.Intn(int(mutationSeq-startSeq))) + } + return uint64(1 + rng.Intn(int(startSeq))) +} + +func applyRandomFields(rng *rand.Rand, b *Bead) { + if rng.Intn(2) == 0 { + b.Title = fmt.Sprintf("t%d", rng.Intn(3)) + } + if rng.Intn(3) == 0 { + b.Labels = []string{fmt.Sprintf("l%d", rng.Intn(2))} + } + if rng.Intn(3) == 0 { + v := rng.Intn(2) == 0 + b.IsBlocked = &v + } + if rng.Intn(3) == 0 { + b.Metadata = StringMap{"k": fmt.Sprintf("%d", rng.Intn(2))} + } + if rng.Intn(3) == 0 { + b.Needs = []string{fmt.Sprintf("n%d", rng.Intn(2))} + } + if rng.Intn(3) == 0 { + b.Dependencies = []Dep{dep(b.ID, fmt.Sprintf("d%d", rng.Intn(2)))} + } +} + +func randDeps(rng *rand.Rand, id string) []Dep { + switch rng.Intn(4) { + case 0: + return nil + case 1: + return []Dep{} + default: + n := 1 + rng.Intn(2) + ds := make([]Dep, n) + for i := range ds { + ds[i] = dep(id, fmt.Sprintf("t%d", rng.Intn(3))) + } + return ds + } +} + +func isClosed(b Bead) bool { return b.Status == "closed" } + +// --------------------------------------------------------------------------- +// Differential tests over the tiers +// --------------------------------------------------------------------------- + +func TestReconcileMergeDifferential_Grid(t *testing.T) { + for _, f := range genGridStates() { + f := f + for _, bd := range []bool{false, true} { + st := cloneStoreState(f.st) + st.backingIsBd = bd + assertDifferential(t, f.name+backingSuffix(bd), st, cloneSnapshotInputs(f.in)) + } + } +} + +func TestReconcileMergeDifferential_Marginal(t *testing.T) { + for _, f := range genMarginalStates() { + f := f + for _, bd := range []bool{false, true} { + st := cloneStoreState(f.st) + st.backingIsBd = bd + assertDifferential(t, f.name+backingSuffix(bd), st, cloneSnapshotInputs(f.in)) + } + } +} + +func TestReconcileMergeDifferential_Seeded(t *testing.T) { + states := genSeededStates(1, 12000) + for _, f := range states { + assertDifferential(t, f.name, cloneStoreState(f.st), cloneSnapshotInputs(f.in)) + } +} + +func backingSuffix(bd bool) string { + if bd { + return "/bd" + } + return "/mem" +} + +// --------------------------------------------------------------------------- +// Coverage assertions +// --------------------------------------------------------------------------- + +func classifyAllInto(rec *coverageRecorder, states []mergeFixture) { + // Classify the SAME (deep-cloned) state+inputs the differential actually + // runs — cloneStoreState/cloneSnapshotInputs collapse empty deps slices to + // nil exactly as production's cloneDeps does, so classification and + // execution can never disagree on an unreachable empty-deps cell. + for _, f := range states { + for _, bd := range []bool{false, true} { + st := cloneStoreState(f.st) + st.backingIsBd = bd + in := cloneSnapshotInputs(f.in) + for _, id := range rowIDUniverse(st, in) { + rec.record(classifyRow(st, in, id)) + } + } + } +} + +func TestReconcileMergeCoverage_AllCellsExecuted(t *testing.T) { + rec := newCoverageRecorder() + classifyAllInto(rec, genGridStates()) + classifyAllInto(rec, genMarginalStates()) + classifyAllInto(rec, mergeFixtures()) + classifyAllInto(rec, genSeededStates(1, 3000)) + + // 1. Full guard-axis cross: every intended guard cell must be observed. + var missing []string + forEachGuardCell(func(spec guardCellSpec) { + gc := guardCell{spec.regime, spec.presence, spec.tomb, spec.seq, spec.recency, spec.changed, expectedStatusPair(spec)} + if rec.guards[gc] == 0 { + missing = append(missing, fmt.Sprintf("%+v", gc)) + } + }) + if len(missing) > 0 { + t.Fatalf("%d guard cells never executed (generator/classifier drift):\n%s", + len(missing), joinLimited(missing, 25)) + } + + // 2. Marginal coverage: every value of every soft/hardened axis observed. + // Empty (non-nil) deps slices are V-excluded: production's cloneDeps and + // depsFromBeadFields collapse them to nil, so absent / present-nil / + // present-nonempty are the only reachable deps-presence cells. + requireMarginal(t, rec, "dirty", []string{"true", "false"}) + requireMarginal(t, rec, "depMapCell", []string{"na", "nil", "nonempty"}) + requireMarginal(t, rec, "fieldDeps", []string{"na", "none", "needs", "deps", "both"}) + requireMarginal(t, rec, "cachedDeps", []string{"absent", "nil", "nonempty"}) + requireMarginal(t, rec, "useFreshDeps", []string{"true", "false"}) + requireMarginal(t, rec, "backingIsBd", []string{"true", "false"}) + requireMarginal(t, rec, "confirmedClosed", []string{"true", "false"}) + requireMarginal(t, rec, "preserveOutcome", []string{"inapplicable", "no-cached", "applied", "blocked-deps", "blocked-target", "na"}) + requireMarginal(t, rec, "recency", []string{"none", "recent", "boundary", "justover", "stale", "now"}) +} + +func TestReconcileMergeCoverage_QuiescentCellsExecuted(t *testing.T) { + // The Branch-B deletion precondition: every B-reachable (quiescent) guard + // cell must have been exercised against the frozen Branch B. + rec := newCoverageRecorder() + classifyAllInto(rec, genGridStates()) + classifyAllInto(rec, genMarginalStates()) + classifyAllInto(rec, mergeFixtures()) + classifyAllInto(rec, genSeededStates(1, 3000)) + + var missing []string + forEachGuardCell(func(spec guardCellSpec) { + if spec.regime != "quiescent" { + return + } + gc := guardCell{spec.regime, spec.presence, spec.tomb, spec.seq, spec.recency, spec.changed, expectedStatusPair(spec)} + if rec.guards[gc] == 0 { + missing = append(missing, fmt.Sprintf("%+v", gc)) + } + }) + if len(missing) > 0 { + t.Fatalf("%d quiescent guard cells never executed — Branch B deletion is NOT safe:\n%s", + len(missing), joinLimited(missing, 25)) + } +} + +func expectedStatusPair(spec guardCellSpec) string { + switch spec.presence { + case "both": + return spec.cachedStatus + ">" + spec.freshStatus + case "snap": + return "?>" + spec.freshStatus + case "cache": + return spec.cachedStatus + ">?" + default: + return "na" + } +} + +func requireMarginal(t *testing.T, rec *coverageRecorder, axis string, vals []string) { + t.Helper() + for _, v := range vals { + if rec.marginal[axis+"="+v] == 0 { + t.Errorf("marginal coverage gap: %s=%s never observed", axis, v) + } + } +} + +func joinLimited(ss []string, n int) string { + if len(ss) > n { + ss = append(ss[:n:n], fmt.Sprintf("... (+%d more)", len(ss)-n)) + } + out := "" + for _, s := range ss { + out += " " + s + "\n" + } + return out +} diff --git a/internal/beads/caching_store_reconcile_oracle_test.go b/internal/beads/caching_store_reconcile_oracle_test.go new file mode 100644 index 0000000000..528032fbad --- /dev/null +++ b/internal/beads/caching_store_reconcile_oracle_test.go @@ -0,0 +1,508 @@ +package beads + +// The equivalence oracle for the reconcile differential gate. +// +// Design (proved in the plan §5 and re-derived here): +// * The collapsed pipeline's absorb and eviction loops are line-for-line +// transliterations of Branch A's loops, and the GC sweep emits no +// notifications and touches no counters. Therefore NEW's notification +// multiset, add/remove/update counters, and every seam-written scalar +// (state, lastFreshAt, mutationSeq, primeErr, syncFailures, stats times) +// are IDENTICAL to the reference branch on every input — no delta. +// * Only the six per-row maps + depsComplete diverge, and only on the +// exactly-enumerated §2 delta id-sets. +// +// The oracle builds the FULL expected NEW end-state from the reference +// end-state: scalars/notifications copied verbatim (asserting exact equality), +// the six maps + depsComplete transformed per an INDEPENDENT case-oracle that +// derives the §2 deltas from the INPUT state alone (it calls the shared pure +// helpers beadChanged/recentLocalMutation/preserve — pinned separately — but +// never reconcileMergeDecision or the merge pipeline). Assertion is exact +// reflect.DeepEqual: this pins apply(delta, refEnd) == newEnd bidirectionally, +// so both under-collection (a missed GC) and over-collection (a GC'd protected +// fence) fail. Spec-independent invariants on NEW-end alone add redundant +// power against a matrix misread. + +import ( + "sort" + "testing" + "time" +) + +// perIDView is the full per-id slice of the six maps, with presence tracked +// separately from value so nil-vs-empty and zero-vs-absent are distinguished. +type perIDView struct { + hasBead bool + bead Bead + hasDeps bool + deps []Dep + dirty bool + hasBeadSeq bool + beadSeq uint64 + hasLocalAt bool + localAt time.Time + hasDeleted bool + deletedSeq uint64 +} + +func viewOf(end mergeEndState, id string) perIDView { + v := perIDView{} + v.bead, v.hasBead = end.beads[id] + v.deps, v.hasDeps = end.deps[id] + _, v.dirty = end.dirty[id] + v.beadSeq, v.hasBeadSeq = end.beadSeq[id] + v.localAt, v.hasLocalAt = end.localBeadAt[id] + v.deletedSeq, v.hasDeleted = end.deletedSeq[id] + return v +} + +func viewOfState(st storeState, id string) perIDView { + v := perIDView{} + v.bead, v.hasBead = st.beads[id] + v.deps, v.hasDeps = st.deps[id] + _, v.dirty = st.dirty[id] + v.beadSeq, v.hasBeadSeq = st.beadSeq[id] + v.localAt, v.hasLocalAt = st.localBeadAt[id] + v.deletedSeq, v.hasDeleted = st.deletedSeq[id] + return v +} + +// deltaKind is the §2 delta an id exhibits (or none). +type deltaKind int + +const ( + deltaNone deltaKind = iota + deltaGCOrphan // D1/D3 (mutated): stale unprotected orphan the sweep collects + deltaD4RecencyKept // quiescent absorb recencyKeep: NEW keeps cached deps + deltaD5RecentAbsorb // quiescent absorb, recent, no recencyKeep: NEW keeps fences + deltaD1RecentOrphan // quiescent orphan with recent localAt: NEW keeps everything +) + +// classifyDelta derives the delta for id from the INPUT (st, in) and the +// post-preserve fresh view. It is written from the §2 matrix and shares no +// code with reconcileMergeDecision or mergeSnapshotLocked. +func classifyDelta(st storeState, in snapshotInputs, postPreserveFresh map[string]Bead, refEnd mergeEndState, id string) deltaKind { + if in.quiescent(st) { + freshBead, f := in.freshByID[id] + _ = freshBead + cached, c := st.beads[id] + recent := recentLocalMutation(st.localBeadAt[id], in.now) + switch { + case f: + recencyKeep := c && recent && beadChanged(cached, postPreserveFresh[id], true) + switch { + case recencyKeep: + return deltaD4RecencyKept + case recent: + return deltaD5RecentAbsorb + default: + return deltaNone + } + case c: + return deltaNone // eviction cell never diverges from B + default: + // orphan (no row either side). Quiescent ⇒ fences <= startSeq + // (invariant Q), so the only protector is recency. + if recent { + return deltaD1RecentOrphan + } + return deltaNone + } + } + // Mutated regime, reference = Branch A end-state. NEW = A + GC sweep. + // Divergence is exactly the orphan ids the sweep collects (D1/D3). + if _, inBeads := refEnd.beads[id]; inBeads { + return deltaNone + } + if _, inFresh := in.freshByID[id]; inFresh { + return deltaNone + } + if !stateHasAnyOrphanEntry(refEnd, id) { + return deltaNone + } + // Protector check against A-end values (the sweep runs on post-loop state). + if refEnd.deletedSeq[id] > in.startSeq || refEnd.beadSeq[id] > in.startSeq { + return deltaNone + } + if recentLocalMutation(refEnd.localBeadAt[id], in.now) { + return deltaNone + } + return deltaGCOrphan +} + +func stateHasAnyOrphanEntry(end mergeEndState, id string) bool { + if _, ok := end.deletedSeq[id]; ok { + return true + } + if _, ok := end.dirty[id]; ok { + return true + } + if _, ok := end.beadSeq[id]; ok { + return true + } + if _, ok := end.localBeadAt[id]; ok { + return true + } + if _, ok := end.deps[id]; ok { + return true + } + return false +} + +// expectedNewView returns the per-id view NEW must produce, transforming the +// reference view per the classified delta. +func expectedNewView(st storeState, _ snapshotInputs, refEnd mergeEndState, id string, kind deltaKind) perIDView { + base := viewOf(refEnd, id) + switch kind { + case deltaNone: + return base + case deltaGCOrphan: + return perIDView{} // fully collected + case deltaD4RecencyKept: + // NEW leaves the cached deps in place instead of installing fresh deps. + base.deps, base.hasDeps = st.deps[id] + return base + case deltaD5RecentAbsorb: + // seqClearGuarded keeps the input beadSeq/localBeadAt through the window. + base.beadSeq, base.hasBeadSeq = st.beadSeq[id] + base.localAt, base.hasLocalAt = st.localBeadAt[id] + return base + case deltaD1RecentOrphan: + // NEW keeps every input orphan entry; B wiped them. + return viewOfState(st, id) + default: + return base + } +} + +// buildExpectedNewEnd assembles the full expected NEW end-state from the +// reference end-state and the per-id case-oracle. +func buildExpectedNewEnd(st storeState, in snapshotInputs, postPreserveFresh map[string]Bead, refEnd mergeEndState) mergeEndState { + exp := refEnd // copies scalars verbatim — asserts exact scalar equality + exp.beads = map[string]Bead{} + exp.deps = map[string][]Dep{} + exp.dirty = map[string]struct{}{} + exp.beadSeq = map[string]uint64{} + exp.localBeadAt = map[string]time.Time{} + exp.deletedSeq = map[string]uint64{} + + for id := range allOracleIDs(st, in, refEnd) { + kind := classifyDelta(st, in, postPreserveFresh, refEnd, id) + v := expectedNewView(st, in, refEnd, id, kind) + if v.hasBead { + exp.beads[id] = v.bead + } + if v.hasDeps { + exp.deps[id] = v.deps + } + if v.dirty { + exp.dirty[id] = struct{}{} + } + if v.hasBeadSeq { + exp.beadSeq[id] = v.beadSeq + } + if v.hasLocalAt { + exp.localBeadAt[id] = v.localAt + } + if v.hasDeleted { + exp.deletedSeq[id] = v.deletedSeq + } + } + // depsComplete is regime-uniform in the collapsed seam: reconcileMergeDecision + // has no regime concept, so the flag is a single fold — useFreshDeps, dropped + // to false the moment any absorb-cell skip leaves the cached deps map an + // unfaithful projection of the fresh scan. Re-derived here independently from + // the input state and the shared pure helpers (never reconcileMergeDecision). + // Without the D4 divergent-deps term this reproduces refEnd.depsComplete for + // BOTH frozen branches exactly; the term adds the degradation the collapse + // deliberately introduces so a recency-keep can no longer serve stale cached + // deps under depsComplete=true. + exp.depsComplete = expectedNextDepsComplete(st, in, postPreserveFresh) + return exp +} + +// expectedNextDepsComplete independently reproduces the seam's nextDepsComplete +// fold: it starts at useFreshDeps and drops to false on any absorb-cell (fresh +// present) skip over a cached row that leaves a deps hole — a fence or recency +// skip whose row has no cached deps entry, or a recency-keep that retains cached +// deps diverging from the fresh snapshot. Fence beats recency, matching the +// decision's arm ordering. Derived from input state + shared pure helpers only. +func expectedNextDepsComplete(st storeState, in snapshotInputs, postPreserveFresh map[string]Bead) bool { + freshDepsByID := computeFreshDepsByID(st, in, postPreserveFresh) + complete := in.useFreshDeps + for id, fresh := range postPreserveFresh { + cached, cachedExists := st.beads[id] + if !cachedExists { + continue // created row: no skip, no degradation + } + cachedDeps, hasCachedDeps := st.deps[id] + switch { + case st.deletedSeq[id] > in.startSeq || st.beadSeq[id] > in.startSeq: + if !hasCachedDeps { + complete = false + } + case recentLocalMutation(st.localBeadAt[id], in.now) && beadChanged(cached, fresh, true): + if !hasCachedDeps || depsChanged(cachedDeps, freshDepsByID[id]) { + complete = false + } + } + } + return complete +} + +// computeFreshDepsByID returns, per fresh row, the deps depsForReconcileLocked +// would compute — the identical fresh-deps input all three implementations feed +// their skip/absorb arms. A pre-merge harness store gives depsForReconcileLocked +// the same cached-deps view (and BdStore vs mem fallback) the live seam reads. +func computeFreshDepsByID(st storeState, in snapshotInputs, postPreserveFresh map[string]Bead) map[string][]Dep { + c, _ := newMergeHarnessStore(st) + out := make(map[string][]Dep, len(postPreserveFresh)) + c.mu.Lock() + for id, fresh := range postPreserveFresh { + out[id] = c.depsForReconcileLocked(id, fresh, in.depMap, in.useFreshDeps) + } + c.mu.Unlock() + return out +} + +// allOracleIDs is the id universe the oracle must decide: every id referenced +// by the reference end-state, the input state, or the snapshot. +func allOracleIDs(st storeState, in snapshotInputs, refEnd mergeEndState) map[string]struct{} { + ids := map[string]struct{}{} + add := func(id string) { ids[id] = struct{}{} } + for id := range refEnd.beads { + add(id) + } + for id := range refEnd.deps { + add(id) + } + for id := range refEnd.dirty { + add(id) + } + for id := range refEnd.beadSeq { + add(id) + } + for id := range refEnd.localBeadAt { + add(id) + } + for id := range refEnd.deletedSeq { + add(id) + } + for id := range st.beads { + add(id) + } + for id := range st.deps { + add(id) + } + for id := range st.dirty { + add(id) + } + for id := range st.beadSeq { + add(id) + } + for id := range st.localBeadAt { + add(id) + } + for id := range st.deletedSeq { + add(id) + } + for id := range in.freshByID { + add(id) + } + return ids +} + +// computePostPreserveFresh returns freshByID after the (shared, unchanged) +// preserve pass, so the case-oracle sees the exact fresh beads all three +// implementations see. +func computePostPreserveFresh(st storeState, in snapshotInputs) map[string]Bead { + c, _ := newMergeHarnessStore(st) + fresh := cloneBeadMap(in.freshByID) + c.mu.Lock() + c.preserveCachedReadyProjectionLocked(fresh, in.depMap, in.useFreshDeps) + c.mu.Unlock() + return fresh +} + +// --------------------------------------------------------------------------- +// Notification multiset comparison +// --------------------------------------------------------------------------- + +func assertNotificationsEqual(t *testing.T, name string, want, got []cacheNotification) { + t.Helper() + assertPerIDUnique(t, name+" (ref)", want) + assertPerIDUnique(t, name+" (new)", got) + ws := sortNotifications(want) + gs := sortNotifications(got) + if len(ws) != len(gs) { + t.Fatalf("%s: notification count ref=%d new=%d\nref=%v\nnew=%v", name, len(ws), len(gs), ws, gs) + } + for i := range ws { + if ws[i].eventType != gs[i].eventType || !beadsIdentical(ws[i].bead, gs[i].bead) { + t.Fatalf("%s: notification[%d] ref={%s %s} new={%s %s} (payload differs=%v)", + name, i, ws[i].eventType, ws[i].bead.ID, gs[i].eventType, gs[i].bead.ID, + !beadsIdentical(ws[i].bead, gs[i].bead)) + } + } +} + +func assertPerIDUnique(t *testing.T, name string, ns []cacheNotification) { + t.Helper() + seen := map[string]struct{}{} + for _, n := range ns { + if _, dup := seen[n.bead.ID]; dup { + t.Fatalf("%s: per-id notification uniqueness broken for %q — the multiset comparison assumption is invalid", name, n.bead.ID) + } + seen[n.bead.ID] = struct{}{} + } +} + +func sortNotifications(ns []cacheNotification) []cacheNotification { + out := make([]cacheNotification, len(ns)) + copy(out, ns) + sort.SliceStable(out, func(i, j int) bool { + if out[i].bead.ID != out[j].bead.ID { + return out[i].bead.ID < out[j].bead.ID + } + return out[i].eventType < out[j].eventType + }) + return out +} + +// beadsIdentical is exact struct equality (reflect.DeepEqual), NOT beadChanged +// — a lost Labels slice or metadata entry must fail even where skipLabels-blind +// comparison would pass it. +func beadsIdentical(a, b Bead) bool { + return reflectDeepEqual(a, b) +} + +// --------------------------------------------------------------------------- +// Spec-independent NEW-end invariants (redundant power vs a matrix misread) +// --------------------------------------------------------------------------- + +func assertNewEndInvariants(t *testing.T, name string, end mergeEndState, in snapshotInputs) { + t.Helper() + // INV1 (no leaks): every orphan id (no bead) carrying any fence/deps entry + // must have a live protector — a fence > startSeq or a recent localAt. + orphanIDs := map[string]struct{}{} + for id := range end.deletedSeq { + orphanIDs[id] = struct{}{} + } + for id := range end.dirty { + orphanIDs[id] = struct{}{} + } + for id := range end.beadSeq { + orphanIDs[id] = struct{}{} + } + for id := range end.localBeadAt { + orphanIDs[id] = struct{}{} + } + for id := range end.deps { + orphanIDs[id] = struct{}{} + } + for id := range orphanIDs { + if _, hasBead := end.beads[id]; hasBead { + continue + } + protected := end.deletedSeq[id] > in.startSeq || + end.beadSeq[id] > in.startSeq || + recentLocalMutation(end.localBeadAt[id], in.now) + if !protected { + t.Fatalf("%s: INV1 leak — orphan %q retained a fence/deps entry with no protector (deletedSeq=%d beadSeq=%d localAt=%v startSeq=%d)", + name, id, end.deletedSeq[id], end.beadSeq[id], end.localBeadAt[id], in.startSeq) + } + } + // INV2 (V1): deletedSeq present ⇒ beads absent. + for id := range end.deletedSeq { + if _, ok := end.beads[id]; ok { + t.Fatalf("%s: INV2 violated — %q has both a live row and a tombstone", name, id) + } + } + // INV3: the sentinel convention forbids a zero-valued fence entry. + for id, v := range end.beadSeq { + if v == 0 { + t.Fatalf("%s: INV3 violated — beadSeq[%q]==0 (zero means absent by convention)", name, id) + } + } + for id, v := range end.deletedSeq { + if v == 0 { + t.Fatalf("%s: INV3 violated — deletedSeq[%q]==0", name, id) + } + } +} + +// --------------------------------------------------------------------------- +// The differential assertion +// --------------------------------------------------------------------------- + +// assertDifferential runs the frozen reference branch (selected by regime) and +// the live collapsed seam on byte-identical clones of (st, in), and asserts +// full end-state + notification equivalence modulo the §2 deltas. Each +// implementation is run twice on fresh clones (Go randomizes map iteration per +// range) to detect any order dependence before cross-comparing — an +// order-dependent divergence would otherwise surface as an unreproducible CI +// flake. +func assertDifferential(t *testing.T, name string, st storeState, in snapshotInputs) { + t.Helper() + + // Normalize inputs exactly as the seam does before any implementation or the + // oracle reads them. cloneStoreState/cloneSnapshotInputs run cloneDeps over + // every deps entry, collapsing an empty-non-nil []Dep{} to nil just as the + // live seam stores it. The three impl runs already clone internally, but the + // case-oracle reads st directly (viewOfState/expectedNewView); without this + // the oracle would expect a raw []Dep{} on a recency-kept orphan while the + // seam produced nil — a harness-only false divergence (regression-pinned by + // the FuzzReconcileMergeDifferential seed). + st = cloneStoreState(st) + in = cloneSnapshotInputs(in) + + // Determinism self-check per implementation. + newRes := runNewMerge(cloneStoreState(st), cloneSnapshotInputs(in)) + newRes2 := runNewMerge(cloneStoreState(st), cloneSnapshotInputs(in)) + assertSelfDeterministic(t, name+" NEW", newRes, newRes2) + + var ref mergeImplResult + if in.quiescent(st) { + ref = runLegacyB(cloneStoreState(st), cloneSnapshotInputs(in)) + ref2 := runLegacyB(cloneStoreState(st), cloneSnapshotInputs(in)) + assertSelfDeterministic(t, name+" legacyB", ref, ref2) + } else { + ref = runLegacyA(cloneStoreState(st), cloneSnapshotInputs(in)) + ref2 := runLegacyA(cloneStoreState(st), cloneSnapshotInputs(in)) + assertSelfDeterministic(t, name+" legacyA", ref, ref2) + } + + // Merge purity: zero backing I/O inside the seam (off-BdStore path). + if !st.backingIsBd { + if newRes.backingCalls != 0 { + t.Fatalf("%s: NEW made %d backing calls during merge — the seam must be I/O-free", name, newRes.backingCalls) + } + if ref.backingCalls != 0 { + t.Fatalf("%s: reference made %d backing calls during merge", name, ref.backingCalls) + } + } + + // Notifications and counters must be EXACTLY equal (no delta by analysis). + assertNotificationsEqual(t, name, ref.notifications, newRes.notifications) + + // Six-map + depsComplete: build the full expected NEW end-state from the + // reference and assert exact equality (scalars/counters copied ⇒ asserted + // equal; maps transformed per the independent case-oracle). + postPreserve := computePostPreserveFresh(st, in) + exp := buildExpectedNewEnd(st, in, postPreserve, ref.end) + if !endStatesEqual(exp, newRes.end) { + t.Fatalf("%s: end-state divergence\n%s", name, diffEndStates(exp, newRes.end)) + } + + // Spec-independent invariants on the NEW end-state alone. + assertNewEndInvariants(t, name, newRes.end, in) +} + +func assertSelfDeterministic(t *testing.T, name string, a, b mergeImplResult) { + t.Helper() + if !endStatesEqual(a.end, b.end) { + t.Fatalf("%s: NON-DETERMINISTIC end-state across two runs (map-iteration order dependence)\n%s", + name, diffEndStates(a.end, b.end)) + } + assertNotificationsEqual(t, name+" self-determinism", a.notifications, b.notifications) +} diff --git a/internal/beads/caching_store_reconcile_probes_test.go b/internal/beads/caching_store_reconcile_probes_test.go new file mode 100644 index 0000000000..204ac49b49 --- /dev/null +++ b/internal/beads/caching_store_reconcile_probes_test.go @@ -0,0 +1,490 @@ +package beads + +import ( + "errors" + "strings" + "testing" + "time" +) + +// --------------------------------------------------------------------------- +// Oracle teeth: prove the gate is not vacuous +// --------------------------------------------------------------------------- + +// TestReconcileMergeDeltas_AreReal asserts that every fixture named for a §2 +// delta actually produces a NEW end-state that DIFFERS from the reference +// branch — i.e. the delta is a genuine behavioral change the oracle is +// characterizing, not rubber-stamped equality. assertDifferential still passes +// on these (proving the difference is exactly the enumerated delta). +func TestReconcileMergeDeltas_AreReal(t *testing.T) { + // Markers for fixtures that are GENUINE deltas vs the reference branch. + // Quiescent stale-orphan GC cases are ≡ to Branch B (both wipe), so they are + // deliberately NOT listed here. + deltaMarkers := []string{"D5", "D4", "D2", "D3prime", "D1prime", "recent_kept", "orphan_gc", "fences_gc"} + var checked int + for _, f := range mergeFixtures() { + isDelta := false + for _, m := range deltaMarkers { + if strings.Contains(f.name, m) { + isDelta = true + break + } + } + if !isDelta { + continue + } + st := cloneStoreState(f.st) + in := cloneSnapshotInputs(f.in) + var ref mergeImplResult + if in.quiescent(st) { + ref = runLegacyB(cloneStoreState(st), cloneSnapshotInputs(in)) + } else { + ref = runLegacyA(cloneStoreState(st), cloneSnapshotInputs(in)) + } + newRes := runNewMerge(cloneStoreState(st), cloneSnapshotInputs(in)) + if endStatesEqual(ref.end, newRes.end) { + t.Errorf("%s: labeled a delta but NEW == reference (delta is vacuous)", f.name) + } + checked++ + } + if checked == 0 { + t.Fatal("no delta fixtures found — teeth test is inert") + } +} + +// TestReconcileMergeOracleHasTeeth proves the comparison detects a corrupted +// end-state, guarding against a vacuous reflect.DeepEqual. +func TestReconcileMergeOracleHasTeeth(t *testing.T) { + f := mergeFixtures()[0] + newRes := runNewMerge(cloneStoreState(f.st), cloneSnapshotInputs(f.in)) + corrupt := newRes.end + corrupt.beads = cloneBeadMap(newRes.end.beads) + corrupt.beads["injected-ghost"] = bead("injected-ghost", "open") + if endStatesEqual(newRes.end, corrupt) { + t.Fatal("oracle failed to detect an injected ghost row") + } + // A one-field scalar change must also be caught. + corrupt2 := newRes.end + corrupt2.statsAdds++ + if endStatesEqual(newRes.end, corrupt2) { + t.Fatal("oracle failed to detect a stats.Adds drift") + } +} + +// --------------------------------------------------------------------------- +// Read-projection probe (plan §5.1(5)) +// --------------------------------------------------------------------------- + +// mountEndState builds a live cacheLive store from an end-state over a MemStore +// backing seeded with the "reality" rows (the active beads bd would return). +func mountEndState(end mergeEndState, truth *MemStore) *CachingStore { + c := &CachingStore{ + backing: truth, + beads: cloneBeadMap(end.beads), + deps: cloneDepMap(end.deps), + depsComplete: end.depsComplete, + dirty: cloneDirty(end.dirty), + beadSeq: cloneU64Map(end.beadSeq), + localBeadAt: cloneTimeMap(end.localBeadAt), + deletedSeq: cloneU64Map(end.deletedSeq), + state: cacheLive, + } + ensureMaps(c) + return c +} + +// TestReconcileMergeReadProjection mounts the reference and NEW end-states over +// an identical backing that reflects reality (freshByID rows exist; orphan and +// deleted ids do not) and asserts Get and CachedReady serve identically (or that +// NEW only ever moves in the safe direction). Map equality already implies read +// equality on non-delta cells; this probe is the check that the §2 delta +// divergences are invisible at the read surface — D1's tombstone GC falls +// through to a backing that also says not-found, etc. The dependency-list read +// surface is covered separately by TestReconcileMergeD4RecencyKeepDepListContract, +// which exercises the one delta (D4) that touches deps. +func TestReconcileMergeReadProjection(t *testing.T) { + states := append(mergeFixtures(), genGridStates()...) + states = append(states, genSeededStates(3, 400)...) + for _, f := range states { + f := f + st := cloneStoreState(f.st) + in := cloneSnapshotInputs(f.in) + + var ref mergeImplResult + if in.quiescent(st) { + ref = runLegacyB(cloneStoreState(st), cloneSnapshotInputs(in)) + } else { + ref = runLegacyA(cloneStoreState(st), cloneSnapshotInputs(in)) + } + newRes := runNewMerge(cloneStoreState(st), cloneSnapshotInputs(in)) + + // Backing truth: the active beads a full scan returned this cycle. + truthRef := NewMemStore() + truthNew := NewMemStore() + var truthRows []Bead + for _, b := range in.freshByID { + truthRows = append(truthRows, cloneBead(b)) + } + seedMem(truthRef, truthRows) + seedMem(truthNew, truthRows) + + cRef := mountEndState(ref.end, truthRef) + cNew := mountEndState(newRes.end, truthNew) + + ids := rowIDUniverse(st, in) + ids = append(ids, "never-seen-id") + for _, id := range ids { + bRef, eRef := cRef.Get(id) + bNew, eNew := cNew.Get(id) + if !sameGetResult(bRef, eRef, bNew, eNew) { + t.Fatalf("%s: Get(%q) read-projection divergence ref=(%v,%v) new=(%v,%v)", + f.name, id, bRef.ID, eRef, bNew.ID, eNew) + } + } + + rRef, okRef := cRef.CachedReady() + rNew, okNew := cNew.CachedReady() + switch { + case okRef && okNew: + // Both serve from cache ⇒ identical served set required (beads maps + // are equal on non-GC'd ids, so served readiness must match). + if !sameBeadSet(rRef, rNew) { + t.Fatalf("%s: CachedReady served set differs", f.name) + } + case in.quiescent(st): + // Reference = Branch B. The quiescent deltas (D2 depsComplete, D1'/D3' + // a kept orphan dirty/fence) can only make NEW MORE conservative: + // NEW may decline where B served, never the reverse. + if okNew && !okRef { + t.Fatalf("%s: CachedReady served from NEW but declined on Branch B — unsafe direction", f.name) + } + default: + // Mutated regime, reference = Branch A. The D1/D3 orphan GC REMOVES a + // leaked dirty/fence that made A decline permanently; NEW may serve + // where A declined (the intended fix), never decline where A served. + if okRef && !okNew { + t.Fatalf("%s: CachedReady declined on NEW but Branch A served — a serving regression", f.name) + } + } + } +} + +// TestReconcileMergeD4RecencyKeepDepListContract pins the dependency-read +// contract for the D4 cell — a quiescent recency-keep whose retained cached deps +// diverge from the fresh full-scan snapshot. The collapse keeps the local deps +// (they may reflect an in-flight local write the snapshot lags — see the +// reg_2210 fixture) but must not then advertise the deps map as a complete, +// faithful projection of the scan. This closes the attempt-2 gap: the general +// read-projection probe exercised only Get and CachedReady, leaving the deps +// read surface for this delta unproven. +func TestReconcileMergeD4RecencyKeepDepListContract(t *testing.T) { + const startSeq = uint64(100) + // Quiescent recency-keep: the cached row is recent and body-changed vs the + // fresh row, and its cached deps ([cached]) differ from the fresh snapshot + // deps ([fresh]). + st := storeState{ + beads: map[string]Bead{"a": bead("a", "open")}, + deps: map[string][]Dep{"a": {dep("a", "cached")}}, + beadSeq: map[string]uint64{"a": 90}, + localBeadAt: map[string]time.Time{"a": fxRecent()}, + mutationSeq: startSeq, + } + in := snapshotInputs{ + freshByID: map[string]Bead{"a": beadWith("a", "closed", func(_ *Bead) {})}, + depMap: map[string][]Dep{"a": {dep("a", "fresh")}}, + useFreshDeps: true, + startSeq: startSeq, + now: fxNow, + } + newRes := runNewMerge(cloneStoreState(st), cloneSnapshotInputs(in)) + + // The fix: a divergent recency-keep degrades depsComplete instead of + // over-claiming a complete deps projection. + if newRes.end.depsComplete { + t.Fatal("D4 divergent recency-keep left depsComplete=true — the cache over-claims a complete deps projection") + } + // The retained local deps stay in the cache (the collapse keeps them where + // Branch B overwrote them with the snapshot deps). + if depsChanged(newRes.end.deps["a"], []Dep{dep("a", "cached")}) { + t.Fatalf("D4 recency-keep should retain cached deps, got %v", newRes.end.deps["a"]) + } + + // Mount the NEW end-state over a backing whose DepList is the authoritative + // answer, distinct from both the cached and the snapshot deps, so a fallback + // is observable. + truth := NewMemStore() + truth.deps = []Dep{dep("a", "authoritative")} + c := mountEndState(newRes.end, truth) + + // The public DepList reader fails closed to the backing: with depsComplete + // degraded it must NOT serve the retained (possibly stale) cached deps as an + // authoritative, complete projection. + got, err := c.DepList("a", "down") + if err != nil { + t.Fatalf("DepList returned error: %v", err) + } + if depsChanged(got, []Dep{dep("a", "authoritative")}) { + t.Fatalf("DepList must fall back to the backing when depsComplete is degraded; got %v (cached deps leaked to an authoritative read)", got) + } + + // The cache-only reader still surfaces the retained local deps — the same + // local-truth view cachedGetOnly serves for the retained bead body. It is the + // explicit best-effort cache surface, not the authoritative projection. + cached, err := c.cachedDepListOnly("a", "down") + if err != nil { + t.Fatalf("cachedDepListOnly returned error: %v", err) + } + if depsChanged(cached, []Dep{dep("a", "cached")}) { + t.Fatalf("cachedDepListOnly should surface the retained local deps, got %v", cached) + } +} + +func seedMem(m *MemStore, rows []Bead) { + for _, r := range rows { + m.beads = append(m.beads, cloneBead(r)) + } +} + +func sameGetResult(bRef Bead, eRef error, bNew Bead, eNew error) bool { + refNF := errors.Is(eRef, ErrNotFound) + newNF := errors.Is(eNew, ErrNotFound) + if refNF || newNF { + return refNF && newNF + } + if (eRef == nil) != (eNew == nil) { + return false + } + if eRef != nil { + return eRef.Error() == eNew.Error() + } + return beadsIdentical(bRef, bNew) +} + +func sameBeadSet(a, b []Bead) bool { + if len(a) != len(b) { + return false + } + seen := map[string]Bead{} + for _, x := range a { + seen[x.ID] = x + } + for _, y := range b { + x, ok := seen[y.ID] + if !ok || !beadsIdentical(x, y) { + return false + } + } + return true +} + +// --------------------------------------------------------------------------- +// Aliasing probe (plan §5.1 hardening): equal-clone and shared-backing compare +// identical, so scribble on every input/notification reference field after the +// merge and assert the store's maps do not move — pins today's clone discipline. +// --------------------------------------------------------------------------- + +func TestReconcileMergeNoInputAliasing(t *testing.T) { + for _, f := range mergeFixtures() { + f := f + st := cloneStoreState(f.st) + in := cloneSnapshotInputs(f.in) + + c, _ := newMergeHarnessStore(st) + c.mu.Lock() + res := c.mergeSnapshotLocked(in.freshByID, in.confirmedClosed, in.depMap, in.useFreshDeps, in.startSeq, in.now) + snapshot := captureEndState(c) + c.mu.Unlock() + + // Scribble on every reference field of the inputs and notifications. + scribbleBeadMap(in.freshByID) + scribbleBeadMap(in.confirmedClosed) + for k, v := range in.depMap { + for i := range v { + v[i] = dep("SCRIBBLE", "SCRIBBLE") + } + in.depMap[k] = append(v, dep("EXTRA", "EXTRA")) + } + for i := range res.notifications { + res.notifications[i].bead.Labels = []string{"SCRIBBLE"} + res.notifications[i].bead.Metadata = StringMap{"SCRIBBLE": "1"} + if res.notifications[i].bead.Dependencies != nil { + for j := range res.notifications[i].bead.Dependencies { + res.notifications[i].bead.Dependencies[j] = dep("SCRIBBLE", "SCRIBBLE") + } + } + } + + c.mu.Lock() + after := captureEndState(c) + c.mu.Unlock() + if !endStatesEqual(snapshot, after) { + t.Fatalf("%s: store mutated after scribbling on inputs/notifications — aliasing leak\n%s", + f.name, diffEndStates(snapshot, after)) + } + } +} + +func scribbleBeadMap(m map[string]Bead) { + for k, b := range m { + b.Labels = []string{"SCRIBBLE"} + b.Metadata = StringMap{"SCRIBBLE": "1"} + b.Needs = []string{"SCRIBBLE"} + b.Dependencies = []Dep{dep("SCRIBBLE", "SCRIBBLE")} + m[k] = b + } +} + +// --------------------------------------------------------------------------- +// Fuzz tier +// --------------------------------------------------------------------------- + +type byteCursor struct { + data []byte + pos int +} + +func (c *byteCursor) next() byte { + if c.pos >= len(c.data) { + return 0 + } + b := c.data[c.pos] + c.pos++ + return b +} + +func (c *byteCursor) intn(n int) int { + if n <= 0 { + return 0 + } + return int(c.next()) % n +} + +// decodeFuzzState interprets fuzz bytes as V-valid grid indices (rejection-free). +func decodeFuzzState(data []byte) (storeState, snapshotInputs) { + cur := &byteCursor{data: data} + const startSeq = uint64(100) + mutated := cur.intn(2) == 0 + st := storeState{ + beads: map[string]Bead{}, deps: map[string][]Dep{}, dirty: map[string]struct{}{}, + beadSeq: map[string]uint64{}, localBeadAt: map[string]time.Time{}, deletedSeq: map[string]uint64{}, + backingIsBd: cur.intn(2) == 0, + } + in := snapshotInputs{ + freshByID: map[string]Bead{}, confirmedClosed: map[string]Bead{}, depMap: map[string][]Dep{}, + useFreshDeps: cur.intn(2) == 0, startSeq: startSeq, now: fxNow, + } + if mutated { + st.mutationSeq = startSeq + uint64(1+cur.intn(80)) + } else { + st.mutationSeq = startSeq + } + statuses := []string{"open", "in_progress", "closed"} + recCells := []string{"none", "recent", "boundary", "justover", "stale", "now"} + nRows := 1 + cur.intn(4) + for r := 0; r < nRows; r++ { + id := string(rune('a' + r)) + presence := cur.intn(4) + cachedPresent := presence == 0 || presence == 2 + freshPresent := presence == 0 || presence == 1 + if cachedPresent { + cb := bead(id, statuses[cur.intn(3)]) + fuzzFields(cur, &cb) + st.beads[id] = cb + if cur.intn(3) != 0 { + st.deps[id] = fuzzDeps(cur, id) + } + } + if freshPresent { + fb := bead(id, statuses[cur.intn(3)]) + fuzzFields(cur, &fb) + in.freshByID[id] = fb + if in.useFreshDeps && cur.intn(3) != 0 { + in.depMap[id] = fuzzDeps(cur, id) + } + if cachedPresent && st.beads[id].Status != "closed" && cur.intn(4) == 0 { + in.confirmedClosed[id] = beadWith(id, "closed", func(_ *Bead) {}) + } + } + if cur.intn(2) == 0 { + st.beadSeq[id] = randFenceFuzz(cur, startSeq, st.mutationSeq) + } + if !cachedPresent && cur.intn(2) == 0 { + st.deletedSeq[id] = randFenceFuzz(cur, startSeq, st.mutationSeq) + } + if cur.intn(2) == 0 { + if t, ok := recencyValue2(recCells[cur.intn(len(recCells))]); ok { + st.localBeadAt[id] = t + } + } + if cur.intn(3) == 0 { + st.dirty[id] = struct{}{} + } + if presence == 3 && cur.intn(2) == 0 { + st.deps[id] = fuzzDeps(cur, id) + } + } + return st, in +} + +func randFenceFuzz(cur *byteCursor, startSeq, mutationSeq uint64) uint64 { + if mutationSeq > startSeq && cur.intn(2) == 0 { + return startSeq + 1 + uint64(cur.intn(int(mutationSeq-startSeq))) + } + return uint64(1 + cur.intn(int(startSeq))) +} + +func fuzzFields(cur *byteCursor, b *Bead) { + if cur.intn(2) == 0 { + b.Title = "t" + string(rune('0'+cur.intn(3))) + } + if cur.intn(3) == 0 { + b.Labels = []string{"l" + string(rune('0'+cur.intn(2)))} + } + if cur.intn(3) == 0 { + v := cur.intn(2) == 0 + b.IsBlocked = &v + } + if cur.intn(3) == 0 { + b.Metadata = StringMap{"k": string(rune('0' + cur.intn(2)))} + } + if cur.intn(3) == 0 { + b.Needs = []string{"n" + string(rune('0'+cur.intn(2)))} + } + if cur.intn(3) == 0 { + b.Dependencies = []Dep{dep(b.ID, "d"+string(rune('0'+cur.intn(2))))} + } +} + +func fuzzDeps(cur *byteCursor, id string) []Dep { + switch cur.intn(4) { + case 0: + return nil + case 1: + return []Dep{} + default: + n := 1 + cur.intn(2) + ds := make([]Dep, n) + for i := range ds { + ds[i] = dep(id, "t"+string(rune('0'+cur.intn(3)))) + } + return ds + } +} + +func FuzzReconcileMergeDifferential(f *testing.F) { + // Seed the corpus from a few fixtures' byte-equivalents (arbitrary bytes + // decode to V-valid states, so any seed is legal). + f.Add([]byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}) + f.Add([]byte{1, 1, 1, 0, 2, 2, 2, 3, 3, 3, 4, 4}) + f.Add([]byte{255, 254, 253, 200, 100, 50, 25, 12, 6, 3, 1}) + // Regression seed: decodes to a recent quiescent orphan (delta + // D1RecentOrphan) carrying an empty-non-nil deps entry. Before the harness + // normalized its inputs, the oracle read that raw []Dep{} while the seam + // stored the cloneDeps-normalized nil, producing a false end-state + // divergence. Pinned so the fuzz tier stays honest. + f.Add([]byte("100071101001")) + f.Fuzz(func(t *testing.T, data []byte) { + st, in := decodeFuzzState(data) + assertDifferential(t, "fuzz", st, in) + }) +} diff --git a/internal/beads/caching_store_reconcile_twopass_test.go b/internal/beads/caching_store_reconcile_twopass_test.go new file mode 100644 index 0000000000..a85ad8cab4 --- /dev/null +++ b/internal/beads/caching_store_reconcile_twopass_test.go @@ -0,0 +1,137 @@ +package beads + +import ( + "fmt" + "testing" + "time" +) + +// Tier 4: metamorphic two-pass sequences. From a base state we run one merge, +// apply an intervening REAL-primitive operation (the lifecycle a single-pass +// grid cannot reach — Delete→Update, Update→Delete, event-absorb→update, etc.), +// then re-run the differential on the compound post-op state with a pass-2 +// clock advanced by a delta-now drawn from the fence-window boundary set and a +// startSeq captured either before or after the op. This is the fence-lifecycle +// coverage (#2210/#2987 shape) and the delta-now expiry axis. + +// extractStoreState snapshots a live store's merge-relevant state. +func extractStoreState(c *CachingStore) storeState { + _, isBd := c.backing.(*BdStore) + return storeState{ + beads: cloneBeadMap(c.beads), + deps: cloneDepMap(c.deps), + depsComplete: c.depsComplete, + dirty: cloneDirty(c.dirty), + beadSeq: cloneU64Map(c.beadSeq), + localBeadAt: cloneTimeMap(c.localBeadAt), + deletedSeq: cloneU64Map(c.deletedSeq), + mutationSeq: c.mutationSeq, + backingIsBd: isBd, + } +} + +// twoPassOp is one intervening operation using the real primitives. +type twoPassOp struct { + name string + apply func(c *CachingStore, id string, opNow time.Time) +} + +func twoPassOps() []twoPassOp { + return []twoPassOp{ + {"tombstone", func(c *CachingStore, id string, _ time.Time) { + c.tombstoneLocked(id, c.noteMutationLocked(id)) + }}, + {"markDirty", func(c *CachingStore, id string, _ time.Time) { + c.markDirtyLocked(id) + }}, + {"event_absorb_seqKeep", func(c *CachingStore, id string, opNow time.Time) { + // ApplyEvent-shape: bump the seq fence then absorb keeping it. + c.noteMutationLocked(id) + c.absorbFreshLocked(id, beadWith(id, "in_progress", func(b *Bead) { b.Title = "evt" }), opNow, absorbOpts{ + depsMode: depsFromFields, seqMode: seqKeep, clearDirty: true, + }) + }}, + {"local_update", func(c *CachingStore, id string, opNow time.Time) { + // Update-shape with a CONTROLLED recency stamp (noteLocalMutationLocked + // reads the real wall clock, which we cannot inject; replicate its + // effect deterministically at opNow). + c.mutationSeq++ + c.beadSeq[id] = c.mutationSeq + c.localBeadAt[id] = opNow + c.absorbFreshLocked(id, beadWith(id, "open", func(b *Bead) { b.Title = "upd" }), opNow, absorbOpts{ + depsMode: depsFromFields, seqMode: seqKeep, clearDirty: true, + }) + }}, + {"tombstone_then_update", func(c *CachingStore, id string, opNow time.Time) { + // D1' genesis: Delete then a post-tombstone Update attempt. + c.tombstoneLocked(id, c.noteMutationLocked(id)) + c.mutationSeq++ + c.beadSeq[id] = c.mutationSeq + c.localBeadAt[id] = opNow + c.absorbFreshLocked(id, bead(id, "open"), opNow, absorbOpts{ + depsMode: depsFromFields, seqMode: seqKeep, clearDirty: true, + }) + }}, + } +} + +var deltaNows = []time.Duration{0, 2500 * time.Millisecond, 5 * time.Second, 5001 * time.Millisecond, time.Hour} + +func TestReconcileMergeDifferential_TwoPass(t *testing.T) { + bases := []mergeFixture{} + // A handful of grid states plus a few seeded states as pass-1 inputs. + grid := genGridStates() + for i := 0; i < len(grid); i += 40 { // sample the grid to bound runtime + bases = append(bases, grid[i]) + } + bases = append(bases, genSeededStates(7, 60)...) + + ops := twoPassOps() + targetIDs := []string{"a", "r0"} + + for _, base := range bases { + for _, op := range ops { + for _, dn := range deltaNows { + for _, capAfter := range []bool{false, true} { + name := fmt.Sprintf("%s/%s/dn=%s/after=%v", base.name, op.name, dn, capAfter) + + // Pass 1: differential on the base, then advance a live NEW + // store through pass 1 + the intervening op. + st0 := cloneStoreState(base.st) + in1 := cloneSnapshotInputs(base.in) + assertDifferential(t, name+"/pass1", cloneStoreState(st0), cloneSnapshotInputs(in1)) + + live := cloneSnapshotInputs(in1) // pass1's preserve mutates freshByID + c, _ := newMergeHarnessStore(st0) + c.mu.Lock() + c.mergeSnapshotLocked(live.freshByID, live.confirmedClosed, live.depMap, live.useFreshDeps, live.startSeq, live.now) + seqBefore := c.mutationSeq + opNow := in1.now + id := targetIDs[0] + if _, ok := c.beads["r0"]; ok { + id = "r0" + } + op.apply(c, id, opNow) + seqAfter := c.mutationSeq + st2 := extractStoreState(c) + c.mu.Unlock() + + // Pass 2: fresh differential on the compound post-op state. + startSeq2 := seqAfter + if !capAfter { + startSeq2 = seqBefore // op raced the scan + } + in2 := snapshotInputs{ + freshByID: cloneBeadMap(in1.freshByID), + depMap: cloneDepMap(in1.depMap), + useFreshDeps: in1.useFreshDeps, + startSeq: startSeq2, + now: in1.now.Add(dn), + } + // V4: startSeq <= mutationSeq. seqBefore/seqAfter both satisfy it. + assertDifferential(t, name+"/pass2", st2, in2) + } + } + } + } +} From 738c11517e16e30ab2ca18afb055efb43724188e Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Thu, 9 Jul 2026 12:23:18 -0700 Subject: [PATCH 035/225] simplify(S19) stage 2: durable canonical-identity schema (write-only) (#4064) Stage 2 of the level-triggered reconciler rewrite; write-only + dormant + behavior-preserving; lands canonical_identity.go + Info.CanonicalIdentity projection + create/adoption stamps + primed_at/prompt_hash folded into CommitStartedPatch, with all 7 started_config_hash clear sites consistent + a lifetime-rule gate. Builds on merged stage 1 (#4034). #3872. Spec: engdocs/simplification/specs/S19-stage2-canonical-identity-spec.md --------- Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/gc/adoption_barrier.go | 53 +- cmd/gc/adoption_barrier_test.go | 67 + cmd/gc/prepared_start_priming_test.go | 88 ++ cmd/gc/session_beads.go | 28 +- cmd/gc/session_converge_shadow.go | 1151 +++++++++++++++++ ...ession_converge_shadow_concurrency_test.go | 134 ++ ...sion_converge_shadow_observability_test.go | 58 + ...session_converge_shadow_reconciler_test.go | 101 ++ cmd/gc/session_converge_shadow_test.go | 670 ++++++++++ .../session_converge_shadow_writesite_test.go | 129 ++ cmd/gc/session_identity.go | 27 +- cmd/gc/session_identity_test.go | 79 ++ cmd/gc/session_lifecycle_parallel.go | 76 +- .../session_lifecycle_parallel_phase2_test.go | 13 + cmd/gc/session_lifecycle_parallel_test.go | 71 + cmd/gc/session_name_lookup.go | 14 + cmd/gc/session_reconcile.go | 10 + cmd/gc/session_reconcile_test.go | 10 + cmd/gc/session_reconciler.go | 72 ++ cmd/gc/session_wake_test.go | 5 + cmd/gc/template_resolve.go | 15 +- internal/session/canonical_identity.go | 103 ++ internal/session/canonical_identity_test.go | 106 ++ internal/session/chat.go | 10 + internal/session/info_apply_patch_test.go | 11 +- internal/session/info_codec.go | 8 + internal/session/info_codec_test.go | 8 +- internal/session/lifecycle_exits.go | 3 + internal/session/lifecycle_exits_test.go | 4 + internal/session/lifecycle_transition.go | 90 ++ internal/session/lifecycle_transition_test.go | 33 + internal/session/manager.go | 19 + internal/session/manager_test.go | 44 + .../session/priming_lifetime_gate_test.go | 203 +++ internal/session/priming_markers_test.go | 138 ++ 35 files changed, 3619 insertions(+), 32 deletions(-) create mode 100644 cmd/gc/prepared_start_priming_test.go create mode 100644 cmd/gc/session_converge_shadow.go create mode 100644 cmd/gc/session_converge_shadow_concurrency_test.go create mode 100644 cmd/gc/session_converge_shadow_observability_test.go create mode 100644 cmd/gc/session_converge_shadow_reconciler_test.go create mode 100644 cmd/gc/session_converge_shadow_test.go create mode 100644 cmd/gc/session_converge_shadow_writesite_test.go create mode 100644 internal/session/canonical_identity.go create mode 100644 internal/session/canonical_identity_test.go create mode 100644 internal/session/priming_lifetime_gate_test.go create mode 100644 internal/session/priming_markers_test.go diff --git a/cmd/gc/adoption_barrier.go b/cmd/gc/adoption_barrier.go index 85fe299da7..8bb742a1fa 100644 --- a/cmd/gc/adoption_barrier.go +++ b/cmd/gc/adoption_barrier.go @@ -167,19 +167,20 @@ func runAdoptionBarrier( continue } - // Build bead metadata. Config/live hashes are left empty — - // syncSessionBeads populates them from built agent objects. agent_name - // and pool_slot are stamped below once pool-base resolution completes. - meta := desiredSessionIdentity(sessionIdentityInputs{ - SessionName: sessionName, - State: "active", - Generation: sessionpkg.DefaultGeneration, - ContinuationEpoch: sessionpkg.DefaultContinuationEpoch, - InstanceToken: sessionpkg.NewInstanceToken(), - }) - detail := adoptionDetail{SessionName: sessionName} + // Resolve the canonical agent_name and pool slot BEFORE deriving identity + // metadata, so desiredSessionIdentity emits agent_name/pool_slot (and, for + // config-resolved agents, the durable canonical record) instead of the + // former hand-stamps. resolvedAgentName / resolvedSlot hold exactly the + // values the old hand-stamps used; the orphan arm resolves to + // agent_name=sessionName but is NOT config-resolved, so it mints no + // canonical record (S19 S2-3). + var ( + resolvedAgentName string + resolvedSlot int + ) + if isConfigAgent { if isPoolInstance { // For pool instances, reconstruct the instance name @@ -187,14 +188,14 @@ func runAdoptionBarrier( slot := parsePoolSlot(sessionName) instanceName := fmt.Sprintf("%s-%d", cfgAgent.QualifiedName(), slot) detail.AgentName = instanceName - meta["agent_name"] = instanceName + resolvedAgentName = instanceName } else { detail.AgentName = cfgAgent.QualifiedName() - meta["agent_name"] = cfgAgent.QualifiedName() + resolvedAgentName = cfgAgent.QualifiedName() } } else { detail.AgentName = sessionName - meta["agent_name"] = sessionName + resolvedAgentName = sessionName } // Detect pool instances from session name suffix. @@ -208,7 +209,7 @@ func runAdoptionBarrier( sessionName, cfgAgent.QualifiedName()) case slot > 0 && isConfigAgent && cfgAgent.SupportsInstanceExpansion(): detail.PoolSlot = slot - meta["pool_slot"] = strconv.Itoa(slot) + resolvedSlot = slot if maxSess := cfgAgent.EffectiveMaxActiveSessions(); maxSess != nil && *maxSess >= 0 && slot > *maxSess { detail.OutOfBounds = true fmt.Fprintf(stderr, "adoption barrier: %s pool slot %d exceeds max %d (adopt-then-drain)\n", //nolint:errcheck @@ -226,6 +227,19 @@ func runAdoptionBarrier( sessionName, slot) } + // Build bead metadata. Config/live hashes are left empty — + // syncSessionBeads populates them from built agent objects. + meta := desiredSessionIdentity(sessionIdentityInputs{ + AgentName: resolvedAgentName, + SessionName: sessionName, + State: "active", + Generation: sessionpkg.DefaultGeneration, + ContinuationEpoch: sessionpkg.DefaultContinuationEpoch, + InstanceToken: sessionpkg.NewInstanceToken(), + PoolSlot: resolvedSlot, + ConfigResolved: isConfigAgent, + }) + if dryRun { result.Adopted++ result.Details = append(result.Details, detail) @@ -235,13 +249,18 @@ func runAdoptionBarrier( alreadyHadBead := false createSessionBead := func() error { meta["synced_at"] = clk.Now().UTC().Format("2006-01-02T15:04:05Z07:00") - if _, err := sessFront.CreateSession(sessionpkg.CreateSpec{ + beadID, err := sessFront.CreateSession(sessionpkg.CreateSpec{ Title: detail.AgentName, AgentName: detail.AgentName, Metadata: meta, - }); err != nil { + }) + if err != nil { return fmt.Errorf("creating session bead for %q: %w", sessionName, err) } + // S19 Stage 3 shadow: record the legacy canonical-identity stamp built + // by desiredSessionIdentity for this adopted bead (no-op unless the + // shadow harness is enabled). + recordLegacyCompareWrites(beadID, "adoptionBarrier.create", meta) return nil } createErr := sessionpkg.WithCitySessionIdentifierLocks(cityPath, []string{sessionName, detail.AgentName}, func() error { diff --git a/cmd/gc/adoption_barrier_test.go b/cmd/gc/adoption_barrier_test.go index 4a716faf08..95a0f9db60 100644 --- a/cmd/gc/adoption_barrier_test.go +++ b/cmd/gc/adoption_barrier_test.go @@ -768,6 +768,14 @@ func TestAdoptionBarrier_SingletonWithNumericSuffix(t *testing.T) { if b.Metadata["pool_slot"] != "" { t.Errorf("singleton agent should not have pool_slot, got %q", b.Metadata["pool_slot"]) } + // A2 canonical record (S19 Stage 2, write-only): a config-resolved + // singleton gets a canonical name and NO canonical_pool_slot. + if got := b.Metadata[session.CanonicalInstanceNameMetadata]; got != "db-node-1" { + t.Errorf("singleton canonical_instance_name = %q, want db-node-1", got) + } + if got := b.Metadata[session.CanonicalPoolSlotMetadata]; got != "" { + t.Errorf("singleton canonical_pool_slot = %q, want empty", got) + } } } @@ -805,6 +813,15 @@ func TestAdoptionBarrier_StaleDashNSingletonAdoptsCanonicalIdentity(t *testing.T if b.Metadata["pool_slot"] != "" { t.Errorf("stale singleton session should not have pool_slot metadata, got %q", b.Metadata["pool_slot"]) } + // A2 canonical record (S19 Stage 2, write-only): the stale-dash-N + // singleton is stamped with the CANONICAL base name and NO slot — never + // the phantom refinery-1 pool identity (S2-3 honesty). + if got := b.Metadata[session.CanonicalInstanceNameMetadata]; got != "refinery" { + t.Errorf("stale singleton canonical_instance_name = %q, want refinery", got) + } + if got := b.Metadata[session.CanonicalPoolSlotMetadata]; got != "" { + t.Errorf("stale singleton canonical_pool_slot = %q, want empty", got) + } } } @@ -852,3 +869,53 @@ func TestProcessHintsUsesExplicitAgentProcessNames(t *testing.T) { t.Fatalf("processHints() returned agent slice without cloning") } } + +// TestAdoptionBarrier_StampsCanonicalIdentity proves the A2 canonical stamp +// (S19 Stage 2, write-only): a config-resolved pool instance gets a canonical +// record (name + slot), while an orphan session (ends in -N, matches no agent) +// gets NO canonical record — a wrong authoritative identity is worse than an +// absent one (S2-3). +func TestAdoptionBarrier_StampsCanonicalIdentity(t *testing.T) { + store := beads.NewMemStore() + sp := &fakeAdoptionProvider{running: []string{"worker-3", "orphan-9"}} + cfg := &config.City{ + Agents: []config.Agent{ + {Name: "worker", MinActiveSessions: intPtr(1), MaxActiveSessions: intPtr(5)}, + }, + } + var stderr bytes.Buffer + clk := &clock.Fake{Time: time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC)} + + _, passed := runAdoptionBarrier("", sessionFrontDoor(store), sp, cfg, "test-city", clk, &stderr, false) + if !passed { + t.Fatalf("barrier should pass, stderr: %s", stderr.String()) + } + + beadList, _ := store.ListByLabel(sessionBeadLabel, 0) + byAgent := map[string]beads.Bead{} + for _, b := range beadList { + byAgent[b.Metadata["agent_name"]] = b + } + + pool, ok := byAgent["worker-3"] + if !ok { + t.Fatalf("no adopted bead for pool instance worker-3; beads=%v", byAgent) + } + if got := pool.Metadata[session.CanonicalInstanceNameMetadata]; got != "worker-3" { + t.Errorf("pool canonical_instance_name = %q, want worker-3", got) + } + if got := pool.Metadata[session.CanonicalPoolSlotMetadata]; got != "3" { + t.Errorf("pool canonical_pool_slot = %q, want 3", got) + } + + orphan, ok := byAgent["orphan-9"] + if !ok { + t.Fatalf("no adopted bead for orphan-9; beads=%v", byAgent) + } + if got := orphan.Metadata[session.CanonicalInstanceNameMetadata]; got != "" { + t.Errorf("orphan canonical_instance_name = %q, want empty (no canonical record for orphan)", got) + } + if got := orphan.Metadata[session.CanonicalPoolSlotMetadata]; got != "" { + t.Errorf("orphan canonical_pool_slot = %q, want empty", got) + } +} diff --git a/cmd/gc/prepared_start_priming_test.go b/cmd/gc/prepared_start_priming_test.go new file mode 100644 index 0000000000..7ceb1a6859 --- /dev/null +++ b/cmd/gc/prepared_start_priming_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "testing" + + "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/config" + sessionpkg "github.com/gastownhall/gascity/internal/session" +) + +// TestPreparedStartPromptDelivered pins the S19 B0 trap: prepared.promptDelivered +// is the pure delivery decision AND-ed with the fresh-launch condition, so a +// resume incarnation reports false even though the launch path re-sets +// GC_STARTUP_PROMPT_DELIVERED="1" for hook consumption. It also pins promptHash. +func TestPreparedStartPromptDelivered(t *testing.T) { + const prompt = "do the work" + + cases := []struct { + name string + prompt string + startedHash string // non-empty ⇒ not firstStart + sessionKey string // non-empty ⇒ hasResumeKey + wakeMode string // "fresh" ⇒ forceFresh + wantDelivered bool + }{ + {name: "fresh first start delivers", prompt: prompt, wantDelivered: true}, + {name: "no resume key delivers even with started hash", prompt: prompt, startedHash: "cfg", wantDelivered: true}, + {name: "force fresh delivers despite resume key", prompt: prompt, startedHash: "cfg", sessionKey: "warm", wakeMode: "fresh", wantDelivered: true}, + {name: "resume incarnation does NOT deliver (the trap)", prompt: prompt, startedHash: "cfg", sessionKey: "warm", wantDelivered: false}, + {name: "empty prompt never delivers", prompt: "", startedHash: "", wantDelivered: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + store := beads.NewMemStore() + meta := map[string]string{ + "session_name": "worker", + "template": "worker", + "state": "asleep", + } + if tc.startedHash != "" { + meta["started_config_hash"] = tc.startedHash + } + if tc.sessionKey != "" { + meta["session_key"] = tc.sessionKey + } + if tc.wakeMode != "" { + meta["wake_mode"] = tc.wakeMode + } + session, err := store.Create(beads.Bead{ + Title: "worker", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: meta, + }) + if err != nil { + t.Fatalf("Create(session): %v", err) + } + candidate := startCandidate{ + session: &session, + tp: TemplateParams{ + TemplateName: "worker", + SessionName: "worker", + Command: "claude", + Prompt: tc.prompt, + }, + } + prepared, err := buildPreparedStart(candidate, &config.City{}, store) + if err != nil { + t.Fatalf("buildPreparedStart: %v", err) + } + if prepared.promptDelivered != tc.wantDelivered { + t.Errorf("promptDelivered = %v, want %v", prepared.promptDelivered, tc.wantDelivered) + } + if got, want := prepared.promptHash, sessionpkg.PromptHash(tc.prompt); got != want { + t.Errorf("promptHash = %q, want %q", got, want) + } + // The env marker choreography is untouched: on the resume row it is + // still re-set to "1" even though nothing is delivered — the exact + // reason promptDelivered cannot be inferred from it. + if tc.name == "resume incarnation does NOT deliver (the trap)" { + if prepared.cfg.Env[startupPromptDeliveredEnv] != "1" { + t.Errorf("resume path must still set %s=1 for hooks; got %q", startupPromptDeliveredEnv, prepared.cfg.Env[startupPromptDeliveredEnv]) + } + } + }) + } +} diff --git a/cmd/gc/session_beads.go b/cmd/gc/session_beads.go index a51a64c565..b7e8174311 100644 --- a/cmd/gc/session_beads.go +++ b/cmd/gc/session_beads.go @@ -428,6 +428,11 @@ func reopenClosedConfiguredNamedSessionBead( batch["started_live_hash"] = "" batch["live_hash"] = "" batch["startup_dialog_verified"] = "" + // Priming markers share started_config_hash's lifetime (S19 Stage 2): + // re-claiming for a fresh spawn re-primes. + batch[session.PrimedAtMetadataKey] = "" + batch[session.PrimingAttemptedAtMetadataKey] = "" + batch[session.PromptHashMetadataKey] = "" } else { batch["pending_create_started_at"] = "" } @@ -435,6 +440,10 @@ func reopenClosedConfiguredNamedSessionBead( batch[k] = v } if setMetaBatch(sessionFrontDoor(store), bead.ID, batch, stderr) == nil { + // S19 Stage 3 shadow: record the legacy priming-marker clears so the + // converge comparator can attribute this owned-key delta (no-op unless + // the shadow harness is enabled). + recordLegacyCompareWrites(bead.ID, "syncSessionBeads.reclaim", batch) if bead.Metadata == nil { bead.Metadata = make(map[string]string, len(batch)) } @@ -509,6 +518,10 @@ func retireDuplicateConfiguredNamedSessionBeads( if setMetaBatch(sessionFrontDoor(store), b.ID, batch, stderr) != nil { continue } + // S19 Stage 3 shadow: record the legacy canonical-identity clears so + // the converge comparator can attribute this owned-key delta (no-op + // unless the shadow harness is enabled). + recordLegacyCompareWrites(b.ID, "retireDuplicateConfiguredNamedSessionBeads", batch) if err := sessionFrontDoor(store).SetStatusOpen(b.ID); err != nil { fmt.Fprintf(stderr, "session beads: archiving duplicate named session %s: %v\n", b.ID, err) //nolint:errcheck continue @@ -578,6 +591,10 @@ func retireRemovedConfiguredNamedSessionBead( if setMetaBatch(sessionFrontDoor(store), b.ID, batch, stderr) != nil { return false } + // S19 Stage 3 shadow: record the legacy canonical-identity clears so the + // converge comparator can attribute this owned-key delta (no-op unless the + // shadow harness is enabled). + recordLegacyCompareWrites(b.ID, "retireRemovedConfiguredNamedSessionBead", batch) if err := sessionFrontDoor(store).SetStatusOpen(b.ID); err != nil { fmt.Fprintf(stderr, "session beads: archiving removed named session %s: %v\n", b.ID, err) //nolint:errcheck return false @@ -1265,6 +1282,10 @@ func syncSessionBeadsWithSnapshotAndRigStores( Generation: session.DefaultGeneration, ContinuationEpoch: session.DefaultContinuationEpoch, InstanceToken: instanceToken, + PoolSlot: poolSlot, + // syncSessionBeads iterates configured agents, so agentName is + // always a config-resolved identity — stamp the canonical record. + ConfigResolved: true, }) meta["live_hash"] = liveHash meta["session_origin"] = origin @@ -1309,7 +1330,8 @@ func syncSessionBeadsWithSnapshotAndRigStores( } meta["template"] = qualifiedTemplate if poolSlot > 0 { - meta["pool_slot"] = strconv.Itoa(poolSlot) + // pool_slot is emitted by desiredSessionIdentity above (PoolSlot + // passed in); only the pending pool session_name is hand-stamped. meta["session_name"] = pendingPoolSessionName(qualifiedTemplate, instanceToken) } // Store command and resume fields so gc session attach can @@ -1417,6 +1439,10 @@ func syncSessionBeadsWithSnapshotAndRigStores( case finalizeErr != nil: continue default: + // S19 Stage 3 shadow: record the legacy canonical-identity stamp + // (built by desiredSessionIdentity above) now that the bead ID + // exists. No-op unless the shadow harness is enabled. + recordLegacyCompareWrites(newBead.ID, "syncSessionBeads.create", meta) desiredNames[createdSessionName] = true openIndex[createdSessionName] = newBead.ID openBeads = append(openBeads, newBead) diff --git a/cmd/gc/session_converge_shadow.go b/cmd/gc/session_converge_shadow.go new file mode 100644 index 0000000000..6733cf168b --- /dev/null +++ b/cmd/gc/session_converge_shadow.go @@ -0,0 +1,1151 @@ +package main + +import ( + "fmt" + "os" + "strings" + "sync" + "sync/atomic" + "time" + + sessionpkg "github.com/gastownhall/gascity/internal/session" +) + +// S19 Stage 3 shadow-comparison harness (steps 3a–3c, OBSERVATION-ONLY). +// +// This file proves that deriveConvergeActions (Stage 1) reproduces the legacy +// per-path reconciler behavior EXACTLY, without changing any behavior. Nothing +// here executes an action, double-writes, or mutates a session bead. It only: +// +// 3a — builds per-session {durableFacts, runtimeFacts} from ALREADY-observed +// reconciler state (no new probes, no writes); +// 3b — records legacy writes of the compared metadata keys through an +// in-process synchronous recorder, snapshots those keys at tick start and +// tick end (the owned-key state-diff oracle), and judges derived-vs-legacy +// through a bounded-window replay comparator; +// 3c — increments denominator + divergence counters (no new event type), and +// is validated by a write-site-completeness guard and a seeded-mutation +// canary (both in *_test.go). +// +// The flip (3d double-write), the flag/kill-switch substrate (3e), and real-city +// priming coverage are explicitly NOT in this file — they are later, separately +// gated stages. The harness itself sits behind its own enable latch +// (convergeShadowEnabled), fail-closed to OFF, so a controller that never opts in +// is byte-for-byte identical to the pre-Stage-3 controller. + +// convergeComparedKeys are the durable metadata keys the shadow harness compares +// between the derived action list and the legacy reconciler writes. Every +// non-test cmd/gc write of any of these keys must be wired into the recorder — +// enforced by TestConvergeCompareKeyWriteSitesWired. +var convergeComparedKeys = []string{ + sessionpkg.CanonicalInstanceNameMetadata, + sessionpkg.CanonicalPoolSlotMetadata, + sessionpkg.PrimedAtMetadataKey, + sessionpkg.PrimingAttemptedAtMetadataKey, + sessionpkg.PromptHashMetadataKey, +} + +// convergeCanonicalOwnedKeys are the keys the derived converge loop will OWN +// under P4 and the ONLY keys compared on real cities in Stage 3. The priming +// keys are excluded from real-city comparison (Q1 / hardening 7: +// GC_STARTUP_PROMPT_DELIVERED is launch-env-only and unobservable in a tick, so +// a real-city priming shadow would be a permanent divergence flood). Priming is +// compared on fixtures only, via convergeFixtureOwnedKeys. +var convergeCanonicalOwnedKeys = []string{ + sessionpkg.CanonicalInstanceNameMetadata, + sessionpkg.CanonicalPoolSlotMetadata, +} + +// convergeFixtureOwnedKeys is the full owned set the fixture corpus compares — +// canonical identity PLUS the priming family. Real cities use +// convergeCanonicalOwnedKeys. +var convergeFixtureOwnedKeys = append(append([]string(nil), convergeCanonicalOwnedKeys...), + sessionpkg.PrimedAtMetadataKey, + sessionpkg.PrimingAttemptedAtMetadataKey, + sessionpkg.PromptHashMetadataKey, +) + +// convergeComparedKeySet is a membership set over convergeComparedKeys. +var convergeComparedKeySet = func() map[string]bool { + m := make(map[string]bool, len(convergeComparedKeys)) + for _, k := range convergeComparedKeys { + m[k] = true + } + return m +}() + +// convergeShadowEnabled is the process-wide, fail-closed latch for the shadow +// harness. It is EVALUATED PER CALL — every invocation re-reads +// GC_CONVERGE_SHADOW; the value is not latched or cached (tests toggle it via +// t.Setenv, so do NOT wrap it in sync.OnceValue). An unset, empty, unparseable, +// or false value is hard OFF (legacy-only, byte-identical). +// +// This is the OBSERVER kill-switch (the 138K/day wisp-flood precedent says the +// observer needs one too). It is deliberately NOT the 3e per-city durable +// double-write flag: that substrate belongs to the flip PR (3d/3e), which is out +// of scope for this observation-only harness. An env latch adds no genschema / +// config.Agent surface and is not a liveness status file, so it does not violate +// D7 (see the D7 amendment in the S19 spec). +var convergeShadowEnabled = func() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("GC_CONVERGE_SHADOW"))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +// convergeDivergenceClass is a typed, machine-checkable divergence category. +// Free-text divergence labels are banned (hardening 4): every divergence is one +// of these classes, each with its own counter and triage lane. +type convergeDivergenceClass string + +const ( + // divergenceUnrealizedPrediction: the derivation predicted a compared-key + // write that the legacy path did not make this window. + divergenceUnrealizedPrediction convergeDivergenceClass = "unrealized_prediction" + // divergenceValueMismatch: derived and legacy both wrote a key but with + // different values. + divergenceValueMismatch convergeDivergenceClass = "value_mismatch" + // divergenceUnpredictedDelta: an owned-key delta the derivation did not + // predict but which a legacy recorder entry explains (derivation gap). + divergenceUnpredictedDelta convergeDivergenceClass = "unpredicted_delta" + // divergenceForeignWrite: an owned-key delta that no legacy recorder entry + // explains — either no compared-key write was recorded for it, or a recorded + // write did not materialize into the realized end snapshot. The start/end + // snapshots are built from this process's in-memory bead objects, so this + // detects only IN-PROCESS writers (the wake path, another in-process observer); + // a truly out-of-process writer (e.g. the separate `gc prime` CLI process) + // mutates the store, not these objects, and is not observable here. Must be + // zero for a soak to count. + divergenceForeignWrite convergeDivergenceClass = "foreign_write" + // divergenceFixpointNonEmpty: re-running deriveConvergeActions on END-of-tick + // facts returned a non-empty list (a derivation gap or a mid-tick mutation). + divergenceFixpointNonEmpty convergeDivergenceClass = "fixpoint_non_empty" + // divergenceIdentitySkew: the probe-target name used for fact capture and the + // name the legacy branch probed differ (positive evidence for Stage 5 C4). + divergenceIdentitySkew convergeDivergenceClass = "identity_skew" + // divergenceBoundary: a threshold predicate flipped sign within the measured + // |tickNow - branchNow| window (auto-tolerated timing noise). + divergenceBoundary convergeDivergenceClass = "boundary" + // divergenceWorldMoved: deterministic replay on the values legacy actually + // read reproduced the legacy action — auto-classified and suppressed. + divergenceWorldMoved convergeDivergenceClass = "world_moved" +) + +// convergeSkipReason is a typed reason a session-tick was NOT counted as a clean +// comparison. A skipped session is never "clean" and never "divergent"; it is +// removed from the denominator so "0 divergences" always carries a proven count +// (hardening 2). capture_loss must stay 0 for a soak window to count. +type convergeSkipReason string + +const ( + // skipNotComparable: derived facts and the legacy decision used different + // probe results (e.g. one path probed, the other did not). + skipNotComparable convergeSkipReason = "not_comparable" + // skipCaptureLoss: a required capture (durable facts, snapshot) was missing. + // Must be 0. + skipCaptureLoss convergeSkipReason = "capture_loss" + // skipEarlyContinue: the legacy loop took an early-continue path (drain-ack, + // unknown-state) before the compared region, so there is nothing to compare. + skipEarlyContinue convergeSkipReason = "early_continue" + // skipRecorderContended: a concurrent city tick already owned the process-global + // recorder for this window (the supervisor reconciles each city on its own + // goroutine), so this tick could not record its own legacy writes. Its sessions + // are skipped rather than scored against a recorder it does not own — an honest + // denominator instead of a false-divergence flood. + skipRecorderContended convergeSkipReason = "recorder_contended" +) + +// convergeShadowCounters holds the in-process, monotonic Stage-3 metrics. These +// are the AUTHORITATIVE soak/flip signals (records may sample; counters never). +// No new event type is registered (Q3 / hardening 10). The zero value is ready. +type convergeShadowCounters struct { + mu sync.Mutex + + sessionsEvaluated int64 + sessionsSkipped map[convergeSkipReason]int64 + incomparable int64 + recordsDropped int64 + + compareTotal map[string]int64 // by derived action type + derived map[string]int64 // deriveConvergeActions emissions + divergenceTotal map[convergeDivergenceClass]int64 // by class +} + +// newConvergeShadowCounters returns an initialized counter set. +func newConvergeShadowCounters() *convergeShadowCounters { + return &convergeShadowCounters{ + sessionsSkipped: map[convergeSkipReason]int64{}, + compareTotal: map[string]int64{}, + derived: map[string]int64{}, + divergenceTotal: map[convergeDivergenceClass]int64{}, + } +} + +// convergeShadowMetrics is the process-global counter set the reconciler feeds. +// Tests use isolated instances so the global stays inert unless the harness runs. +var convergeShadowMetrics = newConvergeShadowCounters() + +// convergeShadowTickSeqCounter monotonically numbers reconciler ticks that run +// the shadow harness, so a divergence record can be joined to the tick that +// enqueued its comparison (snapshot vintage). +var convergeShadowTickSeqCounter atomic.Int64 + +// nextConvergeShadowTickSeq returns the next monotonic shadow tick sequence. +func nextConvergeShadowTickSeq() int64 { + return convergeShadowTickSeqCounter.Add(1) +} + +// triFromBool maps a probed boolean into a resolved tri-state. Unknown is never +// produced here — it is reserved for a bit that a branch did not probe at all. +func triFromBool(b bool) convergeTriState { + if b { + return convergeTriTrue + } + return convergeTriFalse +} + +func (c *convergeShadowCounters) incEvaluated() { + c.mu.Lock() + c.sessionsEvaluated++ + c.mu.Unlock() +} + +func (c *convergeShadowCounters) incSkipped(r convergeSkipReason) { + c.mu.Lock() + if c.sessionsSkipped == nil { + c.sessionsSkipped = map[convergeSkipReason]int64{} + } + c.sessionsSkipped[r]++ + c.mu.Unlock() +} + +func (c *convergeShadowCounters) incIncomparable() { + c.mu.Lock() + c.incomparable++ + c.mu.Unlock() +} + +func (c *convergeShadowCounters) incRecordsDropped() { + c.mu.Lock() + c.recordsDropped++ + c.mu.Unlock() +} + +func (c *convergeShadowCounters) incDerived(action string) { + c.mu.Lock() + if c.derived == nil { + c.derived = map[string]int64{} + } + c.derived[action]++ + c.mu.Unlock() +} + +func (c *convergeShadowCounters) incCompare(actionType string) { + c.mu.Lock() + if c.compareTotal == nil { + c.compareTotal = map[string]int64{} + } + c.compareTotal[actionType]++ + c.mu.Unlock() +} + +func (c *convergeShadowCounters) incDivergence(class convergeDivergenceClass) { + c.mu.Lock() + if c.divergenceTotal == nil { + c.divergenceTotal = map[convergeDivergenceClass]int64{} + } + c.divergenceTotal[class]++ + c.mu.Unlock() +} + +// convergeCounterSnapshot is an immutable copy of the counters for assertions. +type convergeCounterSnapshot struct { + SessionsEvaluated int64 + SessionsSkipped map[convergeSkipReason]int64 + Incomparable int64 + RecordsDropped int64 + CompareTotal map[string]int64 + Derived map[string]int64 + DivergenceTotal map[convergeDivergenceClass]int64 +} + +// snapshot returns a deep copy of the counters, safe to read concurrently. +func (c *convergeShadowCounters) snapshot() convergeCounterSnapshot { + c.mu.Lock() + defer c.mu.Unlock() + cp := convergeCounterSnapshot{ + SessionsEvaluated: c.sessionsEvaluated, + Incomparable: c.incomparable, + RecordsDropped: c.recordsDropped, + SessionsSkipped: map[convergeSkipReason]int64{}, + CompareTotal: map[string]int64{}, + Derived: map[string]int64{}, + DivergenceTotal: map[convergeDivergenceClass]int64{}, + } + for k, v := range c.sessionsSkipped { + cp.SessionsSkipped[k] = v + } + for k, v := range c.compareTotal { + cp.CompareTotal[k] = v + } + for k, v := range c.derived { + cp.Derived[k] = v + } + for k, v := range c.divergenceTotal { + cp.DivergenceTotal[k] = v + } + return cp +} + +// survivingDivergences returns the total divergences that survived replay (i.e. +// the classes that count against the acceptance bar). world_moved, boundary, and +// identity_skew are suppressed / positive-evidence classes: they are counted, but +// excluded here because they do not fail the soak. +func (s convergeCounterSnapshot) survivingDivergences() int64 { + var total int64 + for class, n := range s.DivergenceTotal { + switch class { + case divergenceWorldMoved, divergenceBoundary, divergenceIdentitySkew: + // Suppressed / positive-evidence classes: counted, but not a failure. + default: + total += n + } + } + return total +} + +// operatorSummary renders a single bounded, operator-facing line describing the +// shadow soak signal: the proven denominator (evaluated sessions), the typed +// skips that keep it honest, the count of incomparable ticks, the +// surviving-divergence count that gates a soak, and dropped records. It is the +// read path (Q3: no new event type — a behind-latch line on the reconciler's +// existing stderr operator channel) that lets a live GC_CONVERGE_SHADOW soak be +// observed end to end rather than incrementing counters nothing can read. +func (s convergeCounterSnapshot) operatorSummary() string { + var skipped int64 + for _, n := range s.SessionsSkipped { + skipped += n + } + return fmt.Sprintf( + "converge-shadow soak: evaluated=%d skipped=%d incomparable=%d surviving_divergences=%d dropped=%d", + s.SessionsEvaluated, skipped, s.Incomparable, s.survivingDivergences(), s.RecordsDropped, + ) +} + +// --- tri-state runtime facts (3a) --------------------------------------------- + +// convergeTriState expresses a two-bit runtime observation where "unknown" means +// the reconciler branch that owns this session-tick did not probe that bit, so a +// derived fact built from it must not claim a value the legacy path never saw. +type convergeTriState int + +const ( + convergeTriUnknown convergeTriState = iota + convergeTriFalse + convergeTriTrue +) + +// shadowRuntimeCapture is the two-bit, tri-state runtime observation captured at +// the legacy branch's OWN probe site (never a re-probe). runtimePresent is the +// tmux/provider-present bit; processAlive is the child-process-alive bit +// (unknown on paths that only probe presence). Together they express zombies +// (present && !alive). +type shadowRuntimeCapture struct { + probeSite string + probeTarget string + runtimePresent convergeTriState + processAlive convergeTriState + // primedEnv is pinned false on real cities (unobservable in a tick); fixtures + // set it explicitly. + primedEnv bool +} + +// runtimeFacts projects the tri-state capture into the Stage-1 runtimeFacts the +// derivation consumes. observed is true only when at least the presence bit was +// probed; live is present && alive treated conservatively (unknown alive on a +// present runtime is treated as alive on the desired fast path, matching the +// legacy running/alive semantics only when both bits were probed — otherwise the +// tick is marked NOT-COMPARABLE by the caller). +func (rc shadowRuntimeCapture) runtimeFacts() runtimeFacts { + if rc.runtimePresent == convergeTriUnknown { + return runtimeFacts{observed: false} + } + present := rc.runtimePresent == convergeTriTrue + // live requires both bits true; when alive is unknown we conservatively treat + // a present runtime as not-live so no live-only action is emitted from an + // under-probed capture (the comparator marks such ticks NOT-COMPARABLE). + live := present && rc.processAlive == convergeTriTrue + return runtimeFacts{ + observed: true, + live: live, + primedEnv: rc.primedEnv, + } +} + +// fullyProbed reports whether both runtime bits were resolved. A capture that is +// present-only (alive unknown) cannot be compared for live-gated actions. +func (rc shadowRuntimeCapture) fullyProbed() bool { + return rc.runtimePresent != convergeTriUnknown && rc.processAlive != convergeTriUnknown +} + +// --- in-process legacy-action recorder (3b layer 1) --------------------------- + +// legacyCompareWrite is one recorded legacy write of a compared metadata key, +// captured SYNCHRONOUSLY at the write site (no arming, no budget, no async +// queue, cannot be env-disabled independently of the harness itself). +type legacyCompareWrite struct { + sessionID string + key string + value string + writer string + seq int64 +} + +// legacyWriteRecorder is the per-tick synchronous capture channel. It is a plain +// slice guarded by a mutex (the reconciler fans sessions out; the recorder must +// be safe under that). It records ONLY compared keys and only when the harness +// is enabled for the current tick. +type legacyWriteRecorder struct { + mu sync.Mutex + seq int64 + writes []legacyCompareWrite + dropped int64 +} + +// record appends a compared-key write. Non-compared keys are ignored so callers +// can pass a whole batch. A nil recorder is a no-op (the disabled path). +func (r *legacyWriteRecorder) record(sessionID, writer string, batch map[string]string) { + if r == nil || len(batch) == 0 { + return + } + r.mu.Lock() + defer r.mu.Unlock() + for k, v := range batch { + if !convergeComparedKeySet[k] { + continue + } + r.seq++ + r.writes = append(r.writes, legacyCompareWrite{ + sessionID: sessionID, + key: k, + value: v, + writer: writer, + seq: r.seq, + }) + } +} + +// forSession returns the recorded writes for one session, in write order. +func (r *legacyWriteRecorder) forSession(sessionID string) []legacyCompareWrite { + if r == nil { + return nil + } + r.mu.Lock() + defer r.mu.Unlock() + var out []legacyCompareWrite + for _, w := range r.writes { + if w.sessionID == sessionID { + out = append(out, w) + } + } + return out +} + +// convergeGlobalRecorder is the process-global recorder the wired legacy write +// sites feed. It is attached (non-nil) only while a shadow tick is in flight and +// the harness is enabled; otherwise the write-site wrappers see nil and bail. +// +// The supervisor reconciles each city on its own goroutine, so multiple enabled +// ticks can overlap in wall-clock. This is a single-owner slot guarded by an +// ownership token (compare-and-swap): only the tick that installs the recorder +// owns it, and only that tick may clear it (newConvergeShadowTick attaches via +// CAS(nil,rec); convergeShadowTick.detach clears via CAS(rec,nil)). A concurrent +// tick that loses the install CAS is a no-owner — it records nothing of its own +// and skips its sessions at finish — so one city's tick can neither overwrite, +// prematurely clear, nor misattribute another city's compared-key writes. +var convergeGlobalRecorder atomic.Pointer[legacyWriteRecorder] + +// recordLegacyCompareWrites is THE recording wrapper every legacy write site of a +// compared key calls. It is a no-op unless a shadow tick attached a recorder, so +// it adds zero behavior and near-zero cost on the disabled path (one atomic +// load). sessionID may be empty at the map-build sites that pre-date bead +// creation; such calls are dropped with a counted drop so the denominator stays +// honest. +func recordLegacyCompareWrites(sessionID, writer string, batch map[string]string) { + rec := convergeGlobalRecorder.Load() + if rec == nil { + return + } + if strings.TrimSpace(sessionID) == "" { + hasCompared := false + for k := range batch { + if convergeComparedKeySet[k] { + hasCompared = true + break + } + } + if hasCompared { + rec.mu.Lock() + rec.dropped++ + rec.mu.Unlock() + } + return + } + rec.record(sessionID, writer, batch) +} + +// --- owned-key state-diff oracle (3b layer 2) --------------------------------- + +// snapshotComparedKeys reads the compared keys out of a raw metadata map into a +// dense snapshot (missing keys map to ""). It performs no I/O. +func snapshotComparedKeys(meta map[string]string) map[string]string { + snap := make(map[string]string, len(convergeComparedKeys)) + for _, k := range convergeComparedKeys { + snap[k] = meta[k] + } + return snap +} + +// applyDerivedToOwnedKeys is the pure "apply(derivedActions, start)" over the +// owned keys — the oracle's prediction of the end state. It models ONLY the +// enabled/derivable writes; disabled families still contribute their predicted +// owned-key value so the fixpoint and end-state assertions hold on fixtures. +// +// It never invents timestamps: for priming stamps it copies through the caller- +// supplied predicted values (a real executor reuses facts, never recomputes), so +// on real cities — where priming is excluded — only the canonical keys move. +func applyDerivedToOwnedKeys(start map[string]string, actions []sessConvergeAction, pred convergePredictedValues) map[string]string { + end := make(map[string]string, len(start)) + for k, v := range start { + end[k] = v + } + for _, a := range actions { + switch a { + case actionStampCanonicalIdentity: + end[sessionpkg.CanonicalInstanceNameMetadata] = pred.canonicalInstanceName + // Stamp the predicted pool slot for a pooled heal, and CLEAR any stale + // slot for a singleton heal (empty predicted slot). Without the clear, a + // start state carrying a stray canonical_pool_slot plus the newly-stamped + // singleton name would read back through CanonicalIdentityFromMetadata as + // an authoritative pooled identity. + if pred.canonicalPoolSlot != "" { + end[sessionpkg.CanonicalPoolSlotMetadata] = pred.canonicalPoolSlot + } else { + end[sessionpkg.CanonicalPoolSlotMetadata] = "" + } + case actionStampPrimedFromRuntime: + end[sessionpkg.PrimedAtMetadataKey] = pred.primedAt + end[sessionpkg.PromptHashMetadataKey] = pred.promptHash + case actionAttemptPrime: + end[sessionpkg.PrimingAttemptedAtMetadataKey] = pred.primingAttemptedAt + end[sessionpkg.PromptHashMetadataKey] = pred.promptHash + case actionRollbackRuntimeToAbsent: + // Runtime teardown writes no compared metadata key (it kills a pane). + } + } + return end +} + +// convergePredictedValues carries the exact values a real executor would write, +// so applyDerivedToOwnedKeys reuses them verbatim (byte-identical double-apply). +type convergePredictedValues struct { + canonicalInstanceName string + canonicalPoolSlot string + primedAt string + primingAttemptedAt string + promptHash string +} + +// ownedKeyDivergence is a typed owned-key delta the oracle could not reconcile. +type ownedKeyDivergence struct { + sessionID string + key string + class convergeDivergenceClass + predicted string + actual string +} + +// evaluateStateDiffOracle attributes every owned-key delta between the tick-start +// and tick-end snapshots to a typed class. It has two modes: +// +// - Flip / fixture mode (shadowNoExecution == false): the derived actions are +// assumed EXECUTED, so it asserts end == apply(derivedActions, start) over +// the owned keys. A predicted-but-unrealized write is unrealized_prediction; +// a realized-but-unpredicted write is unpredicted_delta (recorder-explained) +// or foreign_write (unexplained); a value disagreement is value_mismatch. +// +// - Shadow / no-execution mode (shadowNoExecution == true, real cities in +// Stage 3): the derived heal is NOT executed, so predicted-but-unrealized +// writes are EXPECTED (the derived-only heal lands at flip, not now) and are +// not flagged. Realized legacy writes that the per-tick derivation +// legitimately does not predict (create/adopt stamps) are likewise not +// flagged. Only two things are divergences in shadow: a foreign write (a +// realized owned-key delta with no recorder entry) and a C4 value-parity +// breach (a derived stamp whose value disagrees with the value legacy +// actually wrote for that key this tick). +func evaluateStateDiffOracle( + sessionID string, + ownedKeys []string, + start, end map[string]string, + actions []sessConvergeAction, + pred convergePredictedValues, + recorded []legacyCompareWrite, + shadowNoExecution bool, +) []ownedKeyDivergence { + predEnd := applyDerivedToOwnedKeys(start, actions, pred) + recordedValue := map[string]string{} + recordedKeys := map[string]bool{} + for _, w := range recorded { + recordedKeys[w.key] = true + recordedValue[w.key] = w.value + } + var out []ownedKeyDivergence + for _, k := range ownedKeys { + predictedChange := predEnd[k] != start[k] + actualChange := end[k] != start[k] + + if shadowNoExecution { + // Real-city shadow: the derived heal is NOT executed, so predicted-but- + // unrealized writes are EXPECTED and not flagged. A recorded legacy write + // only "explains" an owned key when it actually MATERIALIZED (realized end + // value == recorded value); otherwise it never landed (ApplyPatch failed) + // or was overwritten, so the realized state is NOT recorder-explained and + // must not be swallowed as clean — the false-negative this harness most + // needs to avoid. C4 value parity additionally flags a derived stamp whose + // value disagrees with the realized end (== recorded value here, so it + // compares against both). Pulled forward from Stage 5. + switch { + case recordedKeys[k] && end[k] != recordedValue[k]: + out = append(out, ownedKeyDivergence{sessionID, k, divergenceForeignWrite, recordedValue[k], end[k]}) + case recordedKeys[k] && predictedChange && predEnd[k] != end[k]: + out = append(out, ownedKeyDivergence{sessionID, k, divergenceValueMismatch, predEnd[k], end[k]}) + case !recordedKeys[k] && actualChange: + out = append(out, ownedKeyDivergence{sessionID, k, divergenceForeignWrite, "", end[k]}) + } + continue + } + + // Flip / fixture mode: end must equal apply(derived, start). + want := predEnd[k] + got := end[k] + if want == got { + continue + } + switch { + case predictedChange && !actualChange: + out = append(out, ownedKeyDivergence{sessionID, k, divergenceUnrealizedPrediction, want, got}) + case predictedChange && actualChange: + out = append(out, ownedKeyDivergence{sessionID, k, divergenceValueMismatch, want, got}) + case !predictedChange && actualChange: + if recordedKeys[k] { + out = append(out, ownedKeyDivergence{sessionID, k, divergenceUnpredictedDelta, want, got}) + } else { + out = append(out, ownedKeyDivergence{sessionID, k, divergenceForeignWrite, want, got}) + } + } + } + return out +} + +// --- replay comparator (3b layer 3) ------------------------------------------- + +// convergeSuppression models the tick-global legacy couplings that make a +// derived-present / legacy-absent pairing EXPECTED rather than divergent. The +// core derivation stays pure; these live only in the comparator. +type convergeSuppression struct { + // rollbackBudgetExhausted: maxRollbacksPerTick reached, so a derived rollback + // that legacy deferred this tick is expected. + rollbackBudgetExhausted bool + // storeQueryPartial: the store returned a partial view, so legacy skipped + // close/rollback actions this tick. + storeQueryPartial bool + // deferSessionClosesOnBoot: boot-time close deferral is active. + deferSessionClosesOnBoot bool +} + +// suppresses reports whether the given derived action is expected to be absent +// from the legacy path under the active tick-global couplings. +func (s convergeSuppression) suppresses(a sessConvergeAction) bool { + switch a { + case actionRollbackRuntimeToAbsent: + return s.rollbackBudgetExhausted || s.storeQueryPartial || s.deferSessionClosesOnBoot + default: + return false + } +} + +// replayInput is one derived-vs-legacy comparison for a single session-tick. +type replayInput struct { + sessionID string + instanceToken string + // durable/runtime are the facts the derivation used. + durable durableFacts + runtime runtimeFacts + // legacyValues are the values the legacy path actually READ at decision time + // (used for deterministic replay). Empty when the legacy path did not read. + legacyValues durableFacts + legacyRuntime runtimeFacts + // legacyReplayable is true when legacyValues/legacyRuntime were captured, so + // a deterministic replay can run. + legacyReplayable bool + suppression convergeSuppression + // primingExcluded is true on real cities (the priming family is not compared). + primingExcluded bool + // factsProbeTarget and legacyProbeTarget are the resolved names; a mismatch is + // identity-skew. + factsProbeTarget string + legacyProbeTarget string + // boundaryFlip is true when a threshold predicate flips sign within the + // measured |tickNow - branchNow| window. + boundaryFlip bool +} + +// replayVerdict is the comparator's classification of one comparison. +type replayVerdict struct { + // divergences are the surviving, un-suppressed divergence classes. + divergences []convergeDivergenceClass + // suppressed are classes recognized and suppressed (counted, not a failure). + suppressed []convergeDivergenceClass + // comparedActions is the derived action set that was actually compared (drives + // the per-action-type compare quotas). + comparedActions []sessConvergeAction +} + +// isPrimingAction reports whether an action is part of the priming family. +func isPrimingAction(a sessConvergeAction) bool { + return a == actionStampPrimedFromRuntime || a == actionAttemptPrime +} + +// actionName returns a stable string name for counter keying. +func actionName(a sessConvergeAction) string { + switch a { + case actionRollbackRuntimeToAbsent: + return "rollback_runtime_to_absent" + case actionStampCanonicalIdentity: + return "stamp_canonical_identity" + case actionStampPrimedFromRuntime: + return "stamp_primed_from_runtime" + case actionAttemptPrime: + return "attempt_prime" + default: + return "unknown" + } +} + +// compareReplay is the judge. It applies the identity-skew short-circuit, then — +// only when the legacy branch's read facts were captured (legacyReplayable) — +// derives the action set, filters the priming family on real cities, applies the +// boundary short-circuit, models tick-global suppression, and runs deterministic +// replay on the values legacy actually read: if the replay reproduces the derived +// action, the record is auto-classified world_moved and suppressed. The bar is +// "zero divergences that survive replay". +// +// This comparator judges ACTION-SET agreement, which is meaningful only against +// separately-captured legacy facts. Without them the comparison would degenerate +// to derived-vs-derived, so the parity pass is skipped entirely (no hollow +// compare counters) until the Stage-4/5 reader cutover supplies those facts. The +// owned-key state-diff oracle (evaluateStateDiffOracle) judges realized-value +// agreement and remains the live signal for this stage; the two are +// complementary and both feed the counters once replay is available. +func compareReplay(in replayInput) replayVerdict { + var v replayVerdict + + // Identity-skew dominates: if the name used for fact capture differs from the + // name the legacy branch probed, the comparison is not apples-to-apples. + if strings.TrimSpace(in.factsProbeTarget) != "" && + strings.TrimSpace(in.legacyProbeTarget) != "" && + in.factsProbeTarget != in.legacyProbeTarget { + v.suppressed = append(v.suppressed, divergenceIdentitySkew) + return v + } + + // Action-set parity requires the values the legacy branch actually READ, so a + // deterministic replay can reconstruct the legacy action set and a genuine + // derived-vs-legacy mismatch can surface. Without them (legacyReplayable == + // false) the only "legacy" set available is the derived set itself: every + // derived action trivially agrees with itself, no unrealized-prediction / + // unpredicted-delta divergence can arise, and emitting per-action compare + // counters would imply a parity check that never ran. The production + // reconciler cannot supply separate legacy facts until the Stage-4/5 reader + // cutover, so this comparator stays inert there instead of reporting hollow + // agreement; the owned-key state-diff oracle carries the realized-value signal + // for this stage. + if !in.legacyReplayable { + return v + } + + derived := deriveConvergeActions(in.durable, in.runtime) + // The legacy action set is what deriveConvergeActions produces on the values + // legacy actually read (deterministic replay ground truth). + legacy := deriveConvergeActions(in.legacyValues, in.legacyRuntime) + + derivedSet := actionSet(derived) + legacySet := actionSet(legacy) + + for _, a := range derived { + if in.primingExcluded && isPrimingAction(a) { + continue // excluded from real-city comparison (Q1) + } + v.comparedActions = append(v.comparedActions, a) + if legacySet[a] { + continue // agreement + } + // Derived-present, legacy-absent. Classify. + if in.suppression.suppresses(a) { + v.suppressed = append(v.suppressed, divergenceWorldMoved) + continue + } + if in.boundaryFlip { + v.suppressed = append(v.suppressed, divergenceBoundary) + continue + } + // Deterministic replay already produced `legacy`; if it lacks this action + // the world genuinely moved between derivation and legacy read. + v.suppressed = append(v.suppressed, divergenceWorldMoved) + } + + // Legacy-present, derived-absent: a derivation gap (the derivation would miss + // an action legacy takes). Priming excluded on real cities. + for _, a := range legacy { + if in.primingExcluded && isPrimingAction(a) { + continue + } + if derivedSet[a] { + continue + } + if in.boundaryFlip { + v.suppressed = append(v.suppressed, divergenceBoundary) + continue + } + v.divergences = append(v.divergences, divergenceUnpredictedDelta) + } + + return v +} + +// actionSet builds a membership set over an action list. +func actionSet(actions []sessConvergeAction) map[sessConvergeAction]bool { + m := make(map[sessConvergeAction]bool, len(actions)) + for _, a := range actions { + m[a] = true + } + return m +} + +// --- per-tick collector (3a assembly + 3b/3c evaluation) ---------------------- + +// shadowSessionEval bundles everything the harness captured for one session in +// one tick: the assembled facts (3a), the tick-start compared-key snapshot, the +// runtime capture with its probe provenance, and the replay context. The +// reconciler fills it incrementally (durable at loop entry, runtime at the probe +// site) and the collector evaluates it at tick end against the tick-end +// snapshot. +type shadowSessionEval struct { + sessionID string + instanceToken string + durable durableFacts + runtimeCap shadowRuntimeCapture + startSnap map[string]string + pred convergePredictedValues + factsTarget string + legacyTarget string + suppression convergeSuppression + // captured records whether durable facts were ever set (guards capture-loss). + captured bool +} + +// convergeShadowTick is the per-tick collector. It is created only when the +// harness is enabled; a nil *convergeShadowTick makes every method a no-op, so +// the reconciler wiring is byte-identical when the harness is off. +type convergeShadowTick struct { + observerID string + tickSeq int64 + tickNow time.Time + // realCity is true for live-city ticks (priming family excluded); fixtures + // set it false to compare the full owned set. + realCity bool + recorder *legacyWriteRecorder + counters *convergeShadowCounters + evals map[string]*shadowSessionEval + orderedID []string + // owned reports whether this tick won the ownership CAS for the process-global + // recorder. A tick that did not (a concurrent city tick owns it this window) + // records nothing and skips its sessions at finish. + owned bool + // detached guards detach() so it runs exactly once even though both finish and + // the reconciler's safety-net defer call it. + detached bool +} + +// newConvergeShadowTick returns a live collector when the harness is enabled and +// nil otherwise. Callers guard every use with `if tick != nil`, so the disabled +// path costs one comparison. +func newConvergeShadowTick(observerID string, tickSeq int64, tickNow time.Time, realCity bool, counters *convergeShadowCounters) *convergeShadowTick { + if !convergeShadowEnabled() { + return nil + } + rec := &legacyWriteRecorder{} + t := &convergeShadowTick{ + observerID: observerID, + tickSeq: tickSeq, + tickNow: tickNow, + realCity: realCity, + recorder: rec, + counters: counters, + evals: map[string]*shadowSessionEval{}, + } + // Ownership token: install the recorder only if no concurrent city tick already + // holds the slot. The loser stays a no-owner (owned=false) and its write sites + // will observe the winner's recorder but under this tick's globally-unique + // session ids, so they can never cross into the winner's own read-back. + t.owned = convergeGlobalRecorder.CompareAndSwap(nil, rec) + return t +} + +// detach releases this tick's claim on the process-global recorder. It is +// idempotent (finish and the reconciler's safety-net defer both call it) and +// clears the slot only when this tick owns it, via CAS(rec,nil) — so a concurrent +// owner's live recorder is never torn out from under it. A no-owner tick has +// nothing to release. +func (t *convergeShadowTick) detach() { + if t == nil || t.detached { + return + } + t.detached = true + if t.owned { + convergeGlobalRecorder.CompareAndSwap(t.recorder, nil) + } +} + +// captureDurable records the durable facts + tick-start compared-key snapshot for +// a session at Phase-1 loop entry, from ALREADY-observed reconciler state (the +// coherent Info snapshot). No new probes, no writes. +func (t *convergeShadowTick) captureDurable(sessionID, instanceToken, factsTarget string, d durableFacts, startSnap map[string]string, pred convergePredictedValues) { + if t == nil { + return + } + e := t.evals[sessionID] + if e == nil { + e = &shadowSessionEval{sessionID: sessionID} + t.evals[sessionID] = e + t.orderedID = append(t.orderedID, sessionID) + } + e.instanceToken = instanceToken + e.durable = d + e.startSnap = startSnap + e.pred = pred + e.factsTarget = factsTarget + e.captured = true +} + +// captureRuntime records the two-bit runtime observation at the legacy branch's +// OWN probe site (never a re-probe), with its probe provenance. +func (t *convergeShadowTick) captureRuntime(sessionID, probeSite, probeTarget string, present, alive convergeTriState) { + if t == nil { + return + } + e := t.evals[sessionID] + if e == nil { + e = &shadowSessionEval{sessionID: sessionID} + t.evals[sessionID] = e + t.orderedID = append(t.orderedID, sessionID) + } + e.runtimeCap = shadowRuntimeCapture{ + probeSite: probeSite, + probeTarget: probeTarget, + runtimePresent: present, + processAlive: alive, + } + e.legacyTarget = probeTarget +} + +// markSkip records a typed skip reason for a session-tick that cannot be +// compared, keeping the denominator honest. It drops the session from BOTH the +// eval map and the ordered set so finish never re-counts a skipped tick as +// capture-loss — a skipped session leaves the denominator exactly once. +func (t *convergeShadowTick) markSkip(sessionID string, r convergeSkipReason) { //nolint:unparam // typed skip-and-remove primitive over the full skip vocabulary; only skipEarlyContinue needs mid-loop removal today + if t == nil { + return + } + if t.counters != nil { + t.counters.incSkipped(r) + } + if _, ok := t.evals[sessionID]; !ok { + // Never captured (skipped before captureDurable): count once, nothing to drop. + return + } + delete(t.evals, sessionID) + kept := t.orderedID[:0] + for _, id := range t.orderedID { + if id != sessionID { + kept = append(kept, id) + } + } + t.orderedID = kept +} + +// finish evaluates every captured session against its tick-end snapshot (read +// from the coherent post-Phase-1 Info snapshot by the caller, passed via +// endSnaps), runs the oracle + replay comparator, updates counters, and detaches +// the global recorder. It is safe to call on a nil tick. +func (t *convergeShadowTick) finish(endSnaps map[string]map[string]string) { + if t == nil { + return + } + defer t.detach() + + // A concurrent city tick owns the process-global recorder this window, so this + // tick recorded none of its own legacy writes. Scoring against a recorder it + // does not own would flag every owned-key delta as a phantom foreign_write, so + // every captured session is a typed recorder_contended skip instead — the + // denominator stays honest and no false divergence is manufactured. + if !t.owned { + for range t.orderedID { + t.counters.incSkipped(skipRecorderContended) + } + return + } + + ownedKeys := convergeCanonicalOwnedKeys + if !t.realCity { + ownedKeys = convergeFixtureOwnedKeys + } + + for _, id := range t.orderedID { + e := t.evals[id] + if e == nil || !e.captured { + t.counters.incSkipped(skipCaptureLoss) + continue + } + end := endSnaps[id] + if end == nil { + t.counters.incSkipped(skipCaptureLoss) + continue + } + t.evaluateCaptured(e, end, ownedKeys) + } + + t.tallyDroppedRecords() +} + +// evaluateCaptured runs the owned-key oracle, the fixpoint invariant (fixtures +// only), and the replay comparator for one fully captured session against its +// tick-end snapshot, updating the counters. A present-only runtime capture (alive +// unknown) is NOT-COMPARABLE for live-gated actions and leaves the denominator +// instead of being scored. +func (t *convergeShadowTick) evaluateCaptured(e *shadowSessionEval, end map[string]string, ownedKeys []string) { + rf := e.runtimeCap.runtimeFacts() + if e.runtimeCap.runtimePresent == convergeTriTrue && !e.runtimeCap.fullyProbed() { + t.counters.incIncomparable() + t.counters.incSkipped(skipNotComparable) + return + } + + t.counters.incEvaluated() + + actions := deriveConvergeActions(e.durable, rf) + for _, a := range actions { + t.counters.incDerived(actionName(a)) + } + + // Oracle: attribute owned-key deltas. On real cities the derived heal is not + // executed, so shadowNoExecution (== realCity) suppresses the flip-stage + // end==apply(derived,start) assertion and keeps only foreign-write + C4 + // value-parity (no unrealized-prediction flood). + recorded := t.recorder.forSession(e.sessionID) + for _, dv := range evaluateStateDiffOracle(e.sessionID, ownedKeys, e.startSnap, end, actions, e.pred, recorded, t.realCity) { + t.counters.incDivergence(dv.class) + } + + // Fixpoint (fixtures only): re-derive on END-of-tick facts must be empty. In + // real-city shadow the derived heal is unexecuted, so a canonical re-derive + // would be expected-non-empty; running it there would be a permanent false + // positive. + if !t.realCity { + residual := fixpointResidual(e.durable, end, rf) + for i := 0; i < residual; i++ { + t.counters.incDivergence(divergenceFixpointNonEmpty) + } + } + + // Replay comparator. The Stage-3 harness captures a single fact set (the + // coherent Info snapshot plus the legacy branch's own probe), so it cannot yet + // supply the SEPARATE legacy-read facts action-set parity needs — that arrives + // with the Stage-4/5 reader cutover. legacyReplayable is therefore false and + // this runs the identity-skew precondition check only; the action-set parity + // pass stays inert instead of comparing the derived action set against itself. + // The owned-key state-diff oracle above is the realized-value signal here. + verdict := compareReplay(replayInput{ + sessionID: e.sessionID, + instanceToken: e.instanceToken, + durable: e.durable, + runtime: rf, + legacyReplayable: false, + suppression: e.suppression, + primingExcluded: t.realCity, + factsProbeTarget: e.factsTarget, + legacyProbeTarget: e.legacyTarget, + }) + for _, a := range verdict.comparedActions { + t.counters.incCompare(actionName(a)) + } + for _, class := range verdict.divergences { + t.counters.incDivergence(class) + } + for _, class := range verdict.suppressed { + t.counters.incDivergence(class) + } +} + +// fixpointResidual re-derives the converge actions on the END-of-tick durable +// facts. A non-empty result is a flip-stage invariant breach (a derivation gap or +// a mid-tick mutation); the residual action count is returned so each is counted. +func fixpointResidual(durable durableFacts, end map[string]string, rf runtimeFacts) int { + endDurable := durable + endDurable.canonicalIdentity = strings.TrimSpace(end[sessionpkg.CanonicalInstanceNameMetadata]) + endDurable.primedAt = end[sessionpkg.PrimedAtMetadataKey] + if v := strings.TrimSpace(end[sessionpkg.PrimingAttemptedAtMetadataKey]); v != "" { + if ts, err := time.Parse(time.RFC3339, v); err == nil { + endDurable.primingAttemptedAt = ts.UTC() + } + } + endDurable.primedPromptHash = end[sessionpkg.PromptHashMetadataKey] + return len(deriveConvergeActions(endDurable, rf)) +} + +// tallyDroppedRecords folds the recorder's dropped-write count (compared-key +// writes seen before a bead ID existed) into the counters. +func (t *convergeShadowTick) tallyDroppedRecords() { + if t.recorder == nil { + return + } + for i := int64(0); i < t.recorder.dropped; i++ { + t.counters.incRecordsDropped() + } +} + +// --- fact builders (3a) ------------------------------------------------------- + +// buildDurableFactsFromInfo assembles durableFacts from the reconciler's ALREADY +// coherent typed Info snapshot plus the raw priming metadata (Info does not +// mirror the priming keys). No probes, no writes. currentPromptHash and +// promptConfigured are template-derived facts the caller resolves; on real +// cities the priming inputs are still captured so the fixpoint stays honest, but +// the priming action FAMILY is excluded from real-city comparison downstream. +func buildDurableFactsFromInfo(info sessionpkg.Info, rawMeta map[string]string, tickNow time.Time) durableFacts { + d := durableFacts{ + primedAt: rawMeta[sessionpkg.PrimedAtMetadataKey], + primedPromptHash: rawMeta[sessionpkg.PromptHashMetadataKey], + canonicalIdentity: info.CanonicalInstanceNameMetadata, + absent: info.Closed, + now: tickNow, + } + if v := strings.TrimSpace(rawMeta[sessionpkg.PrimingAttemptedAtMetadataKey]); v != "" { + if ts, err := time.Parse(time.RFC3339, v); err == nil { + d.primingAttemptedAt = ts.UTC() + } + } + return d +} diff --git a/cmd/gc/session_converge_shadow_concurrency_test.go b/cmd/gc/session_converge_shadow_concurrency_test.go new file mode 100644 index 0000000000..84f358cdf1 --- /dev/null +++ b/cmd/gc/session_converge_shadow_concurrency_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "fmt" + "sync" + "testing" + + sessionpkg "github.com/gastownhall/gascity/internal/session" +) + +// TestConvergeShadowRecorderOwnershipIsolatesConcurrentTicks proves the +// ownership-token fix for the multi-city recorder race: the supervisor +// reconciles each city on its own goroutine, so two enabled ticks can overlap. +// Only the tick that installs the process-global recorder owns it; a concurrent +// second tick is a no-owner. It records nothing of its own, marks its sessions +// with the typed recorder_contended skip (an honest denominator, never a false +// divergence), and — critically — the owner's compared-key reads never pick up +// the contended tick's writes, because writes are keyed by globally-unique +// session bead IDs. This is the "writes cannot cross between recorders" guard. +func TestConvergeShadowRecorderOwnershipIsolatesConcurrentTicks(t *testing.T) { + t.Setenv("GC_CONVERGE_SHADOW", "1") + t.Cleanup(func() { convergeGlobalRecorder.Store(nil) }) + + countersOwner := newConvergeShadowCounters() + countersContended := newConvergeShadowCounters() + + // Owner attaches first and wins the CAS; the contended tick overlaps it. + owner := newConvergeShadowTick("city-owner", 1, fixtureNow, true, countersOwner) + contended := newConvergeShadowTick("city-contended", 2, fixtureNow, true, countersContended) + if owner == nil || contended == nil { + t.Fatal("newConvergeShadowTick returned nil with harness enabled") + } + if !owner.owned { + t.Fatal("first tick must own the process-global recorder") + } + if contended.owned { + t.Fatal("second concurrent tick must NOT own the recorder (ownership token failed)") + } + + const sidOwner = "sess-owner" + const sidContended = "sess-contended" + + // Owner: clean steady state — canonical present at both ends, derivation empty. + owner.captureDurable(sidOwner, "tok-o", "dir/agent-owner", + durableFacts{canonicalIdentity: "dir/agent-owner", now: fixtureNow}, + map[string]string{sessionpkg.CanonicalInstanceNameMetadata: "dir/agent-owner"}, + convergePredictedValues{}) + owner.captureRuntime(sidOwner, "desired", "dir/agent-owner", convergeTriTrue, convergeTriTrue) + + // Contended: an owned-key delta with no recorder entry it can see. If it were + // wrongly scored it would flag foreign_write; instead it must be skipped. + contended.captureDurable(sidContended, "tok-c", "dir/agent-contended", + durableFacts{canonicalIdentity: "", now: fixtureNow}, + map[string]string{}, + convergePredictedValues{canonicalInstanceName: "dir/agent-contended"}) + contended.captureRuntime(sidContended, "desired", "dir/agent-contended", convergeTriTrue, convergeTriTrue) + + // The contended tick's write site goes through the SAME global wrapper the real + // reconciler uses; it lands in the owner's recorder (the only one attached), + // tagged by the contended session's unique id. It must never surface in the + // owner's evaluation. + recordLegacyCompareWrites(sidContended, "syncSessionBeads", map[string]string{ + sessionpkg.CanonicalInstanceNameMetadata: "dir/agent-contended", + }) + + // Finish the contended tick first: it detaches without clearing the owner's + // live recorder, and its session is a typed recorder_contended skip. + contended.finish(map[string]map[string]string{ + sidContended: {sessionpkg.CanonicalInstanceNameMetadata: "dir/agent-contended"}, + }) + snapC := countersContended.snapshot() + if snapC.SessionsSkipped[skipRecorderContended] != 1 { + t.Fatalf("contended tick: recorder_contended skip = %d, want 1 (skips=%v)", snapC.SessionsSkipped[skipRecorderContended], snapC.SessionsSkipped) + } + if snapC.SessionsEvaluated != 0 { + t.Fatalf("contended tick must not evaluate anything, got evaluated=%d", snapC.SessionsEvaluated) + } + if got := snapC.survivingDivergences(); got != 0 { + t.Fatalf("contended tick must not manufacture divergences, got %d (classes: %v)", got, snapC.DivergenceTotal) + } + + // Finish the owner: it evaluates its own clean session and clears the recorder. + owner.finish(map[string]map[string]string{ + sidOwner: {sessionpkg.CanonicalInstanceNameMetadata: "dir/agent-owner"}, + }) + snapO := countersOwner.snapshot() + if snapO.SessionsEvaluated != 1 { + t.Fatalf("owner tick: evaluated = %d, want 1", snapO.SessionsEvaluated) + } + if got := snapO.survivingDivergences(); got != 0 { + t.Fatalf("owner tick's clean session must not diverge because of the contended write, got %d (classes: %v)", got, snapO.DivergenceTotal) + } + + if convergeGlobalRecorder.Load() != nil { + t.Fatal("recorder must be cleared once every tick has detached") + } +} + +// TestConvergeShadowRecorderConcurrentTicksNoRace runs two full tick lifecycles +// concurrently (as the supervisor's per-city goroutines do) and proves the +// attach/record/detach path is race-free and always leaves the global recorder +// cleared, regardless of which tick wins the ownership CAS. Run under -race. +func TestConvergeShadowRecorderConcurrentTicksNoRace(t *testing.T) { + t.Setenv("GC_CONVERGE_SHADOW", "1") + t.Cleanup(func() { convergeGlobalRecorder.Store(nil) }) + + var wg sync.WaitGroup + for i, city := range []string{"city-a", "city-b"} { + wg.Add(1) + go func(seq int, city string) { + defer wg.Done() + counters := newConvergeShadowCounters() + tick := newConvergeShadowTick(city, int64(seq+1), fixtureNow, true, counters) + if tick == nil { + return + } + sid := fmt.Sprintf("sess-%s", city) + tick.captureDurable(sid, "tok", "dir/agent", + durableFacts{canonicalIdentity: "dir/agent", now: fixtureNow}, + map[string]string{sessionpkg.CanonicalInstanceNameMetadata: "dir/agent"}, + convergePredictedValues{}) + tick.captureRuntime(sid, "desired", "dir/agent", convergeTriTrue, convergeTriTrue) + recordLegacyCompareWrites(sid, "syncSessionBeads", map[string]string{ + sessionpkg.CanonicalInstanceNameMetadata: "dir/agent", + }) + tick.finish(map[string]map[string]string{sid: {sessionpkg.CanonicalInstanceNameMetadata: "dir/agent"}}) + }(i, city) + } + wg.Wait() + + if convergeGlobalRecorder.Load() != nil { + t.Fatal("global recorder must be nil after all concurrent ticks detach") + } +} diff --git a/cmd/gc/session_converge_shadow_observability_test.go b/cmd/gc/session_converge_shadow_observability_test.go new file mode 100644 index 0000000000..5b24b91f24 --- /dev/null +++ b/cmd/gc/session_converge_shadow_observability_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "strings" + "testing" + + "github.com/gastownhall/gascity/internal/beads" + sessionpkg "github.com/gastownhall/gascity/internal/session" +) + +// TestConvergeShadowOperatorSummaryReportsSoakSignal proves the counters have an +// operator-visible read path: a clean fixture tick renders a bounded summary line +// carrying the proven denominator (evaluated) and the surviving-divergence count +// that gates a soak. Without this, enabling GC_CONVERGE_SHADOW increments counters +// nothing can read. +func TestConvergeShadowOperatorSummaryReportsSoakSignal(t *testing.T) { + // converged_steady_state_noop: one evaluated session, zero surviving divergences. + snap := runFixture(t, convergeCleanCorpus()[0]) + line := snap.operatorSummary() + if !strings.Contains(line, "evaluated=1") { + t.Fatalf("operator summary must report the proven denominator, got %q", line) + } + if !strings.Contains(line, "surviving_divergences=0") { + t.Fatalf("operator summary must report zero surviving divergences for a clean tick, got %q", line) + } +} + +// TestConvergeShadowReconcilerEmitsOperatorSummary proves the read path is wired +// end to end: a live enabled reconcile tick writes the soak summary to the +// reconciler's stderr operator channel, reporting a nonzero denominator and zero +// surviving divergences (a live soak can be observed, not just unit-tested). +func TestConvergeShadowReconcilerEmitsOperatorSummary(t *testing.T) { + t.Setenv("GC_CONVERGE_SHADOW", "1") + prev := convergeShadowMetrics + convergeShadowMetrics = newConvergeShadowCounters() + t.Cleanup(func() { convergeShadowMetrics = prev }) + + env := newReconcilerTestEnv() + env.addDesired("worker", "worker", true) + session := env.createSessionBead("worker", "worker") + env.markSessionActive(&session) + env.setSessionMetadata(&session, map[string]string{ + sessionpkg.CanonicalInstanceNameMetadata: "worker", + }) + + env.reconcile([]beads.Bead{session}) + + out := env.stderr.String() + if !strings.Contains(out, "converge-shadow soak:") { + t.Fatalf("reconciler did not emit the shadow soak summary to stderr; stderr=%q", out) + } + if !strings.Contains(out, "surviving_divergences=0") { + t.Fatalf("live tick summary must report zero surviving divergences; stderr=%q", out) + } + if snap := convergeShadowMetrics.snapshot(); snap.SessionsEvaluated == 0 { + t.Fatalf("live tick must move the denominator; evaluated=0 (skips=%v)", snap.SessionsSkipped) + } +} diff --git a/cmd/gc/session_converge_shadow_reconciler_test.go b/cmd/gc/session_converge_shadow_reconciler_test.go new file mode 100644 index 0000000000..1f7cc0d219 --- /dev/null +++ b/cmd/gc/session_converge_shadow_reconciler_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "testing" + + "github.com/gastownhall/gascity/internal/beads" + sessionpkg "github.com/gastownhall/gascity/internal/session" +) + +// TestConvergeShadowReconcilerWiringLive proves the 3a fact capture is actually +// wired into the reconciler's Phase 1 (not silently dead): with the harness +// enabled, reconciling a live desired session moves the denominator +// (sessions_evaluated) and produces zero surviving divergences on this +// steady-state tick. A flatlined denominator here would be a wiring failure, not +// a pass (hardening 2). +func TestConvergeShadowReconcilerWiringLive(t *testing.T) { + t.Setenv("GC_CONVERGE_SHADOW", "1") + // Use an isolated counter set so the assertion is deterministic regardless of + // other tests that may have run the global harness. + prev := convergeShadowMetrics + convergeShadowMetrics = newConvergeShadowCounters() + t.Cleanup(func() { convergeShadowMetrics = prev }) + + env := newReconcilerTestEnv() + env.addDesired("worker", "worker", true) + session := env.createSessionBead("worker", "worker") + env.markSessionActive(&session) + // Stamp a canonical identity so the steady-state tick derives nothing and the + // comparison is a clean ∅-on-live. + env.setSessionMetadata(&session, map[string]string{ + sessionpkg.CanonicalInstanceNameMetadata: "worker", + }) + + env.reconcile([]beads.Bead{session}) + + snap := convergeShadowMetrics.snapshot() + if snap.SessionsEvaluated == 0 && snap.Incomparable == 0 { + t.Fatalf("shadow harness wiring is dead: nothing evaluated (skipped: %v)", snap.SessionsSkipped) + } + if got := snap.survivingDivergences(); got != 0 { + t.Fatalf("steady-state tick produced %d surviving divergences (classes: %v)", got, snap.DivergenceTotal) + } +} + +// TestConvergeShadowReconcilerEarlyContinueSkipped proves a pre-probe +// early-continue path (here a bead with an unrecognized state, which the +// forward-compat unknown-state branch skips BEFORE any runtime probe) is removed +// from the shadow denominator with a typed skipEarlyContinue instead of being +// counted as an evaluated clean comparison. Durable facts are captured at loop +// entry, so without the markSkip wiring this session would reach finish with no +// runtime probe and inflate sessions_evaluated (hardening 2). +func TestConvergeShadowReconcilerEarlyContinueSkipped(t *testing.T) { + t.Setenv("GC_CONVERGE_SHADOW", "1") + prev := convergeShadowMetrics + convergeShadowMetrics = newConvergeShadowCounters() + t.Cleanup(func() { convergeShadowMetrics = prev }) + + env := newReconcilerTestEnv() + env.addDesired("worker", "worker", true) + session := env.createSessionBead("worker", "worker") + // An unrecognized state drives the forward-compat unknown-state early-continue. + env.setSessionMetadata(&session, map[string]string{"state": "archived"}) + + env.reconcile([]beads.Bead{session}) + + snap := convergeShadowMetrics.snapshot() + if snap.SessionsSkipped[skipEarlyContinue] == 0 { + t.Fatalf("early-continue tick was not skipped: skips=%v evaluated=%d", snap.SessionsSkipped, snap.SessionsEvaluated) + } + if snap.SessionsEvaluated != 0 { + t.Fatalf("unknown-state tick inflated the denominator: evaluated=%d", snap.SessionsEvaluated) + } + if snap.SessionsSkipped[skipCaptureLoss] != 0 { + t.Fatalf("skipped tick double-counted as capture_loss: %d", snap.SessionsSkipped[skipCaptureLoss]) + } +} + +// TestConvergeShadowReconcilerDisabledInert proves the reconciler is inert when +// the harness is off: the global recorder is never attached and the denominator +// does not move. +func TestConvergeShadowReconcilerDisabledInert(t *testing.T) { + t.Setenv("GC_CONVERGE_SHADOW", "") + prev := convergeShadowMetrics + convergeShadowMetrics = newConvergeShadowCounters() + t.Cleanup(func() { convergeShadowMetrics = prev }) + + env := newReconcilerTestEnv() + env.addDesired("worker", "worker", true) + session := env.createSessionBead("worker", "worker") + env.markSessionActive(&session) + + env.reconcile([]beads.Bead{session}) + + if convergeGlobalRecorder.Load() != nil { + t.Fatal("global recorder attached with harness disabled") + } + snap := convergeShadowMetrics.snapshot() + if snap.SessionsEvaluated != 0 { + t.Fatalf("denominator moved with harness disabled: %d", snap.SessionsEvaluated) + } +} diff --git a/cmd/gc/session_converge_shadow_test.go b/cmd/gc/session_converge_shadow_test.go new file mode 100644 index 0000000000..30691dc919 --- /dev/null +++ b/cmd/gc/session_converge_shadow_test.go @@ -0,0 +1,670 @@ +package main + +import ( + "testing" + "time" + + sessionpkg "github.com/gastownhall/gascity/internal/session" +) + +// convergeFixture is one row of the shadow harness's golden corpus. Each fixture +// is a fully specified session-tick: the facts the derivation sees, the +// tick-start compared-key snapshot, the predicted executor values, the legacy +// writes recorded this tick, and the realized tick-end snapshot. The corpus is +// derived from the truth table, not intuition. +type convergeFixture struct { + name string + durable durableFacts + runtimeCap shadowRuntimeCapture + start map[string]string + end map[string]string + pred convergePredictedValues + recorded []legacyCompareWrite + realCity bool + // wantSurviving is the number of divergences that must survive replay (i.e. + // count against the acceptance bar) for this fixture. The clean corpus is all + // zeros; the canary flips one to non-zero via a broken derivation. + wantSurviving int64 +} + +var fixtureNow = time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC) + +// convergeCleanCorpus is the blocking CI corpus: every row must produce zero +// surviving divergences. Rows map truth-table cross-product cases and crash +// windows to named fixtures. +func convergeCleanCorpus() []convergeFixture { + canonName := "dir/agent-1" + return []convergeFixture{ + { + name: "converged_steady_state_noop", + durable: durableFacts{ + canonicalIdentity: canonName, + primedAt: "2026-07-08T11:00:00Z", + promptConfigured: true, + now: fixtureNow, + }, + runtimeCap: shadowRuntimeCapture{probeSite: "desired", probeTarget: canonName, runtimePresent: convergeTriTrue, processAlive: convergeTriTrue}, + start: map[string]string{sessionpkg.CanonicalInstanceNameMetadata: canonName, sessionpkg.PrimedAtMetadataKey: "2026-07-08T11:00:00Z"}, + end: map[string]string{sessionpkg.CanonicalInstanceNameMetadata: canonName, sessionpkg.PrimedAtMetadataKey: "2026-07-08T11:00:00Z"}, + realCity: true, + }, + { + name: "canonical_heal_derived_only_realcity", + durable: durableFacts{ + canonicalIdentity: "", + now: fixtureNow, + }, + runtimeCap: shadowRuntimeCapture{probeSite: "desired", probeTarget: canonName, runtimePresent: convergeTriTrue, processAlive: convergeTriTrue}, + start: map[string]string{}, + // Legacy healed the canonical record this tick to the same value the + // executor would write -> byte-identical, zero surviving divergence. + end: map[string]string{sessionpkg.CanonicalInstanceNameMetadata: canonName}, + pred: convergePredictedValues{canonicalInstanceName: canonName}, + recorded: []legacyCompareWrite{{key: sessionpkg.CanonicalInstanceNameMetadata, value: canonName, writer: "syncSessionBeads"}}, + realCity: true, + }, + { + name: "canonical_heal_with_pool_slot", + durable: durableFacts{ + canonicalIdentity: "", + now: fixtureNow, + }, + runtimeCap: shadowRuntimeCapture{probeSite: "desired", probeTarget: canonName, runtimePresent: convergeTriTrue, processAlive: convergeTriTrue}, + start: map[string]string{}, + end: map[string]string{sessionpkg.CanonicalInstanceNameMetadata: canonName, sessionpkg.CanonicalPoolSlotMetadata: "3"}, + pred: convergePredictedValues{canonicalInstanceName: canonName, canonicalPoolSlot: "3"}, + recorded: []legacyCompareWrite{ + {key: sessionpkg.CanonicalInstanceNameMetadata, value: canonName, writer: "poolCreate"}, + {key: sessionpkg.CanonicalPoolSlotMetadata, value: "3", writer: "poolCreate"}, + }, + realCity: true, + }, + { + name: "canonical_singleton_heal_clears_stale_slot", + // The canonical record is absent so the derivation stamps a SINGLETON + // name (empty predicted slot), while a stale canonical_pool_slot from a + // prior pooled incarnation sits in the start snapshot. Fixture mode + // (realCity:false) assumes the heal executed, so end==apply(derived,start) + // must clear the stale slot; without the clear this fixture flags a + // divergence, which is the canary for the S19 singleton-heal slot leak. + durable: durableFacts{ + canonicalIdentity: "", + now: fixtureNow, + }, + runtimeCap: shadowRuntimeCapture{probeSite: "desired", probeTarget: canonName, runtimePresent: convergeTriTrue, processAlive: convergeTriTrue}, + start: map[string]string{sessionpkg.CanonicalPoolSlotMetadata: "3"}, + end: map[string]string{sessionpkg.CanonicalInstanceNameMetadata: canonName}, + pred: convergePredictedValues{canonicalInstanceName: canonName}, + recorded: []legacyCompareWrite{ + {key: sessionpkg.CanonicalInstanceNameMetadata, value: canonName, writer: "syncSessionBeads"}, + {key: sessionpkg.CanonicalPoolSlotMetadata, value: "", writer: "syncSessionBeads"}, + }, + realCity: false, + }, + { + name: "absent_closed_bead_no_heal", + durable: durableFacts{ + canonicalIdentity: "", + absent: true, + now: fixtureNow, + }, + // Unobserved runtime under absent intent -> derivation is empty. + runtimeCap: shadowRuntimeCapture{probeSite: "orphan", probeTarget: canonName, runtimePresent: convergeTriFalse, processAlive: convergeTriFalse}, + start: map[string]string{}, + end: map[string]string{}, + realCity: true, + }, + { + name: "rollback_absent_live_runtime_realcity", + durable: durableFacts{ + absent: true, + now: fixtureNow, + }, + runtimeCap: shadowRuntimeCapture{probeSite: "orphan", probeTarget: canonName, runtimePresent: convergeTriTrue, processAlive: convergeTriTrue}, + start: map[string]string{}, + end: map[string]string{}, // rollback writes no compared key + realCity: true, + }, + { + name: "priming_attempt_fixture_only", + durable: durableFacts{ + canonicalIdentity: canonName, + promptConfigured: true, + primedAt: "", + currentPromptHash: "hash-v1", + now: fixtureNow, + }, + runtimeCap: shadowRuntimeCapture{probeSite: "desired", probeTarget: canonName, runtimePresent: convergeTriTrue, processAlive: convergeTriTrue}, + start: map[string]string{sessionpkg.CanonicalInstanceNameMetadata: canonName}, + end: map[string]string{ + sessionpkg.CanonicalInstanceNameMetadata: canonName, + sessionpkg.PrimingAttemptedAtMetadataKey: "2026-07-08T12:00:00Z", + sessionpkg.PromptHashMetadataKey: "hash-v1", + }, + pred: convergePredictedValues{ + primingAttemptedAt: "2026-07-08T12:00:00Z", + promptHash: "hash-v1", + }, + recorded: []legacyCompareWrite{ + {key: sessionpkg.PrimingAttemptedAtMetadataKey, value: "2026-07-08T12:00:00Z", writer: "attemptPrime"}, + {key: sessionpkg.PromptHashMetadataKey, value: "hash-v1", writer: "attemptPrime"}, + }, + realCity: false, // fixtures compare the full owned set incl. priming + }, + { + name: "priming_stamp_from_runtime_fixture_only", + durable: durableFacts{ + canonicalIdentity: canonName, + promptConfigured: true, + primedAt: "", + currentPromptHash: "hash-v2", + now: fixtureNow, + }, + runtimeCap: shadowRuntimeCapture{probeSite: "desired", probeTarget: canonName, runtimePresent: convergeTriTrue, processAlive: convergeTriTrue, primedEnv: true}, + start: map[string]string{sessionpkg.CanonicalInstanceNameMetadata: canonName}, + end: map[string]string{ + sessionpkg.CanonicalInstanceNameMetadata: canonName, + sessionpkg.PrimedAtMetadataKey: "2026-07-08T12:00:00Z", + sessionpkg.PromptHashMetadataKey: "hash-v2", + }, + pred: convergePredictedValues{ + primedAt: "2026-07-08T12:00:00Z", + promptHash: "hash-v2", + }, + recorded: []legacyCompareWrite{ + {key: sessionpkg.PrimedAtMetadataKey, value: "2026-07-08T12:00:00Z", writer: "stampPrimed"}, + {key: sessionpkg.PromptHashMetadataKey, value: "hash-v2", writer: "stampPrimed"}, + }, + realCity: false, + }, + { + name: "canonical_absent_derived_only_no_legacy_write_realcity", + // The canonical record is absent and legacy does NOT heal it this tick + // (per-tick heal is the derived-only future behavior). The derivation + // wants to stamp; in shadow it is NOT executed, so end stays absent. + // This must produce ZERO divergences (no unrealized-prediction flood). + durable: durableFacts{ + canonicalIdentity: "", + now: fixtureNow, + }, + runtimeCap: shadowRuntimeCapture{probeSite: "desired", probeTarget: canonName, runtimePresent: convergeTriTrue, processAlive: convergeTriTrue}, + start: map[string]string{}, + end: map[string]string{}, // legacy did not write; shadow did not execute + pred: convergePredictedValues{canonicalInstanceName: canonName}, + realCity: true, + }, + { + name: "priming_excluded_on_realcity_no_divergence", + // primedEnv is unobservable on real cities (pinned false); the runtime + // legacy-primed this incarnation but the durable marker is absent. On a + // real city this must NOT flag — priming is excluded. + durable: durableFacts{ + canonicalIdentity: canonName, + promptConfigured: true, + primedAt: "", + now: fixtureNow, + }, + runtimeCap: shadowRuntimeCapture{probeSite: "desired", probeTarget: canonName, runtimePresent: convergeTriTrue, processAlive: convergeTriTrue, primedEnv: false}, + start: map[string]string{sessionpkg.CanonicalInstanceNameMetadata: canonName}, + end: map[string]string{sessionpkg.CanonicalInstanceNameMetadata: canonName}, + realCity: true, + }, + } +} + +// runFixture evaluates one fixture through a fresh tick collector with the +// harness force-enabled, and returns the resulting counter snapshot. +func runFixture(t *testing.T, f convergeFixture) convergeCounterSnapshot { + t.Helper() + t.Setenv("GC_CONVERGE_SHADOW", "1") + counters := newConvergeShadowCounters() + tick := newConvergeShadowTick("observer-test", 1, fixtureNow, f.realCity, counters) + if tick == nil { + t.Fatal("newConvergeShadowTick returned nil with harness enabled") + } + const sid = "sess-1" + tick.captureDurable(sid, "tok-1", f.runtimeCap.probeTarget, f.durable, f.start, f.pred) + tick.captureRuntime(sid, f.runtimeCap.probeSite, f.runtimeCap.probeTarget, f.runtimeCap.runtimePresent, f.runtimeCap.processAlive) + // Preserve primedEnv from the fixture (captureRuntime does not carry it). + tick.evals[sid].runtimeCap.primedEnv = f.runtimeCap.primedEnv + // Feed recorded legacy writes. + for _, w := range f.recorded { + tick.recorder.record(sid, w.writer, map[string]string{w.key: w.value}) + } + tick.finish(map[string]map[string]string{sid: f.end}) + return counters.snapshot() +} + +// TestConvergeShadowCleanCorpus is the blocking CI gate: every fixture in the +// clean corpus produces zero surviving divergences and a proven denominator. +func TestConvergeShadowCleanCorpus(t *testing.T) { + for _, f := range convergeCleanCorpus() { + t.Run(f.name, func(t *testing.T) { + snap := runFixture(t, f) + if got := snap.survivingDivergences(); got != f.wantSurviving { + t.Errorf("%s: surviving divergences = %d, want %d (classes: %v)", f.name, got, f.wantSurviving, snap.DivergenceTotal) + } + if snap.SessionsEvaluated == 0 && snap.Incomparable == 0 { + t.Errorf("%s: nothing evaluated — flatlined denominator is a harness failure, not a pass", f.name) + } + if snap.RecordsDropped != 0 { + t.Errorf("%s: records_dropped = %d, must be 0", f.name, snap.RecordsDropped) + } + }) + } +} + +// TestConvergeShadowSeededMutationCanary injects a deliberately broken derivation +// and asserts the comparator TRIPS within one tick on the affected fixtures. A +// dead comparator and a perfect derivation both report 0 divergences; this proves +// which one we have. Required pre-soak self-test (3c) — wired to gates.canary. +func TestConvergeShadowSeededMutationCanary(t *testing.T) { + // Seed 1: drop actionStampCanonicalIdentity. The heal fixture must flag: the + // legacy path stamped the canonical record (end != start) but the crippled + // derivation predicts no write -> unpredicted_delta (recorder explains it). + t.Run("drop_canonical_stamp", func(t *testing.T) { + // The fixture is the CORRECT converged world (canonical present at tick + // end), reached under fixture/execution semantics (realCity=false). The + // broken derivation "forgot to heal": it claims the record is already + // present (durable.canonicalIdentity set) so it emits no stamp, even though + // the record was absent at tick start. The full oracle must flag the + // realized-but-unpredicted canonical delta within this one tick. + f := convergeFixture{ + name: "canary_drop_canonical", + durable: durableFacts{ + canonicalIdentity: "dir/agent-1", // broken: derivation emits nothing + now: fixtureNow, + }, + runtimeCap: shadowRuntimeCapture{probeSite: "desired", probeTarget: "dir/agent-1", runtimePresent: convergeTriTrue, processAlive: convergeTriTrue}, + start: map[string]string{}, // record was absent at tick start + end: map[string]string{sessionpkg.CanonicalInstanceNameMetadata: "dir/agent-1"}, + pred: convergePredictedValues{canonicalInstanceName: "dir/agent-1"}, + recorded: []legacyCompareWrite{{key: sessionpkg.CanonicalInstanceNameMetadata, value: "dir/agent-1", writer: "syncSessionBeads"}}, + realCity: false, // fixture/execution semantics -> full oracle + } + snap := runFixture(t, f) + if snap.survivingDivergences() == 0 { + t.Fatalf("canary did not trip: comparator is dead (divergences: %v)", snap.DivergenceTotal) + } + }) + + // Seed 2: emit a WRONG canonical slot. Legacy stamped slot 3; a broken + // executor prediction of slot 9 must surface value_mismatch. + t.Run("wrong_slot_value_mismatch", func(t *testing.T) { + snap := runFixtureWrongSlot(t) + if snap.DivergenceTotal[divergenceValueMismatch] == 0 { + t.Fatalf("canary did not trip on wrong slot: %v", snap.DivergenceTotal) + } + }) +} + +// runFixtureWrongSlot models a derivation that predicts the wrong canonical slot +// value than legacy actually wrote. +func runFixtureWrongSlot(t *testing.T) convergeCounterSnapshot { + t.Helper() + t.Setenv("GC_CONVERGE_SHADOW", "1") + counters := newConvergeShadowCounters() + tick := newConvergeShadowTick("observer-test", 1, fixtureNow, true, counters) + const sid = "sess-1" + tick.captureDurable(sid, "tok-1", "dir/agent-1", + durableFacts{canonicalIdentity: "", now: fixtureNow}, + map[string]string{}, + convergePredictedValues{canonicalInstanceName: "dir/agent-1", canonicalPoolSlot: "9"}, // WRONG: predicts 9 + ) + tick.captureRuntime(sid, "desired", "dir/agent-1", convergeTriTrue, convergeTriTrue) + tick.recorder.record(sid, "poolCreate", map[string]string{ + sessionpkg.CanonicalInstanceNameMetadata: "dir/agent-1", + sessionpkg.CanonicalPoolSlotMetadata: "3", + }) + tick.finish(map[string]map[string]string{sid: { + sessionpkg.CanonicalInstanceNameMetadata: "dir/agent-1", + sessionpkg.CanonicalPoolSlotMetadata: "3", // legacy wrote 3 + }}) + return counters.snapshot() +} + +// TestConvergeShadowDisabledIsNoop asserts the harness is byte-identically inert +// when GC_CONVERGE_SHADOW is unset: newConvergeShadowTick returns nil, every +// method is a no-op, and the global recorder is never attached. +func TestConvergeShadowDisabledIsNoop(t *testing.T) { + t.Setenv("GC_CONVERGE_SHADOW", "") + tick := newConvergeShadowTick("observer", 1, fixtureNow, true, newConvergeShadowCounters()) + if tick != nil { + t.Fatal("expected nil tick when harness disabled") + } + // Nil-method safety. + tick.captureDurable("s", "t", "n", durableFacts{}, nil, convergePredictedValues{}) + tick.captureRuntime("s", "site", "n", convergeTriTrue, convergeTriTrue) + tick.markSkip("s", skipEarlyContinue) + tick.finish(nil) + if convergeGlobalRecorder.Load() != nil { + t.Fatal("global recorder must not be attached when disabled") + } + // The write-site wrapper must be a no-op with no recorder attached. + recordLegacyCompareWrites("s", "writer", map[string]string{sessionpkg.CanonicalInstanceNameMetadata: "x"}) +} + +// TestConvergeForeignWriteDetected asserts an owned-key delta with no recorder +// entry and no derived prediction is attributed FOREIGN_WRITE (its own lane). +func TestConvergeForeignWriteDetected(t *testing.T) { + dv := evaluateStateDiffOracle("s", convergeCanonicalOwnedKeys, + map[string]string{}, // start: absent + map[string]string{sessionpkg.CanonicalInstanceNameMetadata: "surprise"}, // end: appeared + nil, // derivation predicted nothing + convergePredictedValues{}, // no predicted values + nil, // no recorder entry + false, // flip/fixture mode + ) + if len(dv) != 1 || dv[0].class != divergenceForeignWrite { + t.Fatalf("expected one foreign_write divergence, got %v", dv) + } +} + +// TestConvergeUnpredictedDeltaWhenRecorded asserts an owned-key delta the +// derivation missed but a recorder entry explains is unpredicted_delta, not +// foreign_write. +func TestConvergeUnpredictedDeltaWhenRecorded(t *testing.T) { + dv := evaluateStateDiffOracle("s", convergeCanonicalOwnedKeys, + map[string]string{}, + map[string]string{sessionpkg.CanonicalInstanceNameMetadata: "healed"}, + nil, + convergePredictedValues{}, + []legacyCompareWrite{{key: sessionpkg.CanonicalInstanceNameMetadata, value: "healed", writer: "legacy"}}, + false, // flip/fixture mode + ) + if len(dv) != 1 || dv[0].class != divergenceUnpredictedDelta { + t.Fatalf("expected one unpredicted_delta, got %v", dv) + } +} + +// TestConvergeShadowRecordedWriteMustMaterialize asserts the real-city +// shadowNoExecution oracle does NOT trust a recorder entry blindly: a recorded +// legacy write only explains an owned key when its value actually landed in the +// realized tick-end snapshot. A recorded write that vanished (end absent) or was +// overwritten (end holds a different value) is a foreign_write divergence, not a +// swallowed clean. This is the false-negative the harness exists to catch before +// the 3d flip records canonical writes inside the tick. +func TestConvergeShadowRecordedWriteMustMaterialize(t *testing.T) { + const worker = "dir/worker-1" + rec := []legacyCompareWrite{{key: sessionpkg.CanonicalInstanceNameMetadata, value: worker, writer: "syncSessionBeads"}} + + t.Run("recorded_but_end_absent_diverges", func(t *testing.T) { + dv := evaluateStateDiffOracle("s", convergeCanonicalOwnedKeys, + map[string]string{}, // start: absent + map[string]string{}, // end: STILL absent — the recorded write never materialized + nil, + convergePredictedValues{}, + rec, // recorder claims legacy wrote canonical=worker this tick + true, // real-city shadow / no-execution mode + ) + if len(dv) != 1 || dv[0].class != divergenceForeignWrite { + t.Fatalf("expected one foreign_write for a recorded-but-unmaterialized write, got %v", dv) + } + if dv[0].actual != "" || dv[0].predicted != worker { + t.Fatalf("expected predicted=%q actual=\"\", got predicted=%q actual=%q", worker, dv[0].predicted, dv[0].actual) + } + }) + + t.Run("recorded_but_end_overwritten_diverges", func(t *testing.T) { + dv := evaluateStateDiffOracle("s", convergeCanonicalOwnedKeys, + map[string]string{}, + map[string]string{sessionpkg.CanonicalInstanceNameMetadata: "dir/other-9"}, // overwritten + nil, + convergePredictedValues{}, + rec, + true, + ) + if len(dv) != 1 || dv[0].class != divergenceForeignWrite { + t.Fatalf("expected one foreign_write for a recorded-then-overwritten write, got %v", dv) + } + }) + + t.Run("recorded_and_materialized_is_clean", func(t *testing.T) { + dv := evaluateStateDiffOracle("s", convergeCanonicalOwnedKeys, + map[string]string{}, + map[string]string{sessionpkg.CanonicalInstanceNameMetadata: worker}, // materialized + []sessConvergeAction{actionStampCanonicalIdentity}, + convergePredictedValues{canonicalInstanceName: worker}, + rec, + true, + ) + if len(dv) != 0 { + t.Fatalf("a recorded write that materialized to the predicted value must be clean, got %v", dv) + } + }) + + t.Run("materialized_but_prediction_value_mismatch_diverges", func(t *testing.T) { + // Recorder + end agree on worker, but the derivation predicted a different + // canonical value: C4 value parity must flag the derived-vs-realized breach. + dv := evaluateStateDiffOracle("s", convergeCanonicalOwnedKeys, + map[string]string{}, + map[string]string{sessionpkg.CanonicalInstanceNameMetadata: worker}, + []sessConvergeAction{actionStampCanonicalIdentity}, + convergePredictedValues{canonicalInstanceName: "dir/wrong-2"}, // derived predicts wrong value + rec, + true, + ) + if len(dv) != 1 || dv[0].class != divergenceValueMismatch { + t.Fatalf("expected one value_mismatch for a wrong derived prediction, got %v", dv) + } + }) +} + +// TestConvergeIdentitySkewSuppressed asserts a probe-target mismatch is +// classified identity-skew (positive evidence) and never a hard divergence. +func TestConvergeIdentitySkewSuppressed(t *testing.T) { + v := compareReplay(replayInput{ + durable: durableFacts{canonicalIdentity: "", now: fixtureNow}, + runtime: runtimeFacts{observed: true, live: true}, + factsProbeTarget: "dir/agent-A", + legacyProbeTarget: "dir/agent-B", + }) + if len(v.divergences) != 0 { + t.Fatalf("identity-skew must not produce hard divergences, got %v", v.divergences) + } + if len(v.suppressed) != 1 || v.suppressed[0] != divergenceIdentitySkew { + t.Fatalf("expected identity_skew suppression, got %v", v.suppressed) + } +} + +// TestConvergeRollbackSuppression asserts a derived rollback that legacy deferred +// under an active tick-global coupling (budget exhausted / partial store) is +// suppressed (world_moved), not a divergence. +func TestConvergeRollbackSuppression(t *testing.T) { + base := replayInput{ + durable: durableFacts{absent: true, now: fixtureNow}, + runtime: runtimeFacts{observed: true, live: true}, + // Legacy replay disabled -> legacy set == derived set, so no derived-absent + // mismatch is possible; force it by making legacy replayable with a + // non-live legacy read (legacy would NOT roll back). + legacyReplayable: true, + legacyValues: durableFacts{absent: true, now: fixtureNow}, + legacyRuntime: runtimeFacts{observed: true, live: false}, + suppression: convergeSuppression{rollbackBudgetExhausted: true}, + } + v := compareReplay(base) + if len(v.divergences) != 0 { + t.Fatalf("expected rollback suppressed, got divergences %v", v.divergences) + } + foundWorldMoved := false + for _, c := range v.suppressed { + if c == divergenceWorldMoved { + foundWorldMoved = true + } + } + if !foundWorldMoved { + t.Fatalf("expected world_moved suppression, got %v", v.suppressed) + } +} + +// TestCompareReplayInertWithoutLegacyFacts pins the Stage-3 production contract: +// with no captured legacy-read facts (legacyReplayable=false) the action-set +// parity comparator emits NOTHING — no compared actions, no divergences, no +// suppressions — even when the facts derive a real action. Comparing the derived +// action set against itself is a tautology, so surfacing per-action compare +// counters or hollow agreement there would be a misleading soak signal. The +// separate identity-skew precondition is covered by +// TestConvergeIdentitySkewSuppressed. +func TestCompareReplayInertWithoutLegacyFacts(t *testing.T) { + in := replayInput{ + // canonical absent => derives actionStampCanonicalIdentity; promptConfigured + // defaults false so exactly one action derives. + durable: durableFacts{canonicalIdentity: "", now: fixtureNow}, + runtime: runtimeFacts{observed: true, live: true}, + // legacyReplayable defaults false: the legacy branch's reads were not captured. + } + if len(deriveConvergeActions(in.durable, in.runtime)) == 0 { + t.Fatal("test setup: facts must derive at least one action to prove the comparator stays inert despite real derived actions") + } + v := compareReplay(in) + if len(v.comparedActions) != 0 { + t.Errorf("comparedActions = %v, want none (no parity counters without captured legacy facts)", v.comparedActions) + } + if len(v.divergences) != 0 { + t.Errorf("divergences = %v, want none (a self-comparison must not surface a divergence)", v.divergences) + } + if len(v.suppressed) != 0 { + t.Errorf("suppressed = %v, want none (no comparison ran without legacy facts)", v.suppressed) + } +} + +// TestCompareReplayDetectsDerivationGapWhenReplayable proves the comparator is +// suppressed-until-facts, not dead: when the legacy branch's read facts ARE +// supplied (legacyReplayable=true), an action legacy would take that the +// derivation misses surfaces as a surviving unpredicted_delta. This is the +// capability the Stage-4/5 reader cutover will feed in production. +func TestCompareReplayDetectsDerivationGapWhenReplayable(t *testing.T) { + v := compareReplay(replayInput{ + // Derivation reads canonical already present => derives NO stamp. + durable: durableFacts{canonicalIdentity: "dir/agent-1", now: fixtureNow}, + runtime: runtimeFacts{observed: true, live: true}, + // Legacy actually read canonical absent => legacy WOULD stamp: a + // legacy-present/derived-absent gap the comparator must flag. + legacyReplayable: true, + legacyValues: durableFacts{canonicalIdentity: "", now: fixtureNow}, + legacyRuntime: runtimeFacts{observed: true, live: true}, + }) + found := false + for _, c := range v.divergences { + if c == divergenceUnpredictedDelta { + found = true + } + } + if !found { + t.Fatalf("expected a surviving unpredicted_delta for a legacy stamp the derivation missed, got divergences=%v suppressed=%v", v.divergences, v.suppressed) + } +} + +// TestConvergeShadowRealCityEmitsNoActionSetCompare is the production-path +// regression guard: on a real city evaluateCaptured has no separate legacy-read +// facts, so it must derive and evaluate the session (honest denominator) WITHOUT +// emitting action-set compare counters that would imply a parity check the +// harness cannot yet run. Before the fix the non-replayable comparator counted +// the derived action as a "compared" agreement; this asserts that hollow counter +// is gone while the real derivation and the owned-key oracle still run. +func TestConvergeShadowRealCityEmitsNoActionSetCompare(t *testing.T) { + t.Setenv("GC_CONVERGE_SHADOW", "1") + counters := newConvergeShadowCounters() + tick := newConvergeShadowTick("observer-test", 1, fixtureNow, true /* realCity */, counters) + if tick == nil { + t.Fatal("newConvergeShadowTick returned nil with harness enabled") + } + const sid = "sess-1" + // Canonical absent at start; legacy stamps it to the predicted value => an + // action derives and the owned-key oracle stays clean. + tick.captureDurable(sid, "tok-1", "dir/agent-1", + durableFacts{canonicalIdentity: "", now: fixtureNow}, + map[string]string{}, + convergePredictedValues{canonicalInstanceName: "dir/agent-1"}, + ) + tick.captureRuntime(sid, "desired", "dir/agent-1", convergeTriTrue, convergeTriTrue) + tick.recorder.record(sid, "poolCreate", map[string]string{ + sessionpkg.CanonicalInstanceNameMetadata: "dir/agent-1", + }) + tick.finish(map[string]map[string]string{sid: { + sessionpkg.CanonicalInstanceNameMetadata: "dir/agent-1", + }}) + snap := counters.snapshot() + + if snap.SessionsEvaluated != 1 { + t.Fatalf("SessionsEvaluated = %d, want 1 (denominator must stay honest)", snap.SessionsEvaluated) + } + if len(snap.Derived) == 0 { + t.Fatalf("Derived = %v, want a derived action (the derivation must still run)", snap.Derived) + } + if len(snap.CompareTotal) != 0 { + t.Errorf("CompareTotal = %v, want empty (no action-set parity counters without captured legacy facts)", snap.CompareTotal) + } + if got := snap.survivingDivergences(); got != 0 { + t.Errorf("survivingDivergences = %d, want 0 (a clean stamp is not a divergence); classes: %v", got, snap.DivergenceTotal) + } +} + +// TestApplyDerivedToOwnedKeysIdempotent asserts applying the empty action list +// leaves the snapshot unchanged (C2 idempotence at the oracle boundary). +func TestApplyDerivedToOwnedKeysIdempotent(t *testing.T) { + start := map[string]string{sessionpkg.CanonicalInstanceNameMetadata: "x", sessionpkg.CanonicalPoolSlotMetadata: "2"} + end := applyDerivedToOwnedKeys(start, nil, convergePredictedValues{}) + for k, v := range start { + if end[k] != v { + t.Errorf("key %q: got %q want %q", k, end[k], v) + } + } +} + +// TestApplyDerivedToOwnedKeysClearsStaleSingletonSlot pins that a singleton heal +// (empty predicted pool slot) CLEARS a stale canonical_pool_slot carried in the +// start snapshot, so {canonical_instance_name:"", canonical_pool_slot:"3"} heals +// to a singleton rather than a stray-slot pooled identity. Regression guard for +// the S19 shadow oracle preserving a stale slot on singleton canonical heal. +func TestApplyDerivedToOwnedKeysClearsStaleSingletonSlot(t *testing.T) { + start := map[string]string{sessionpkg.CanonicalPoolSlotMetadata: "3"} + end := applyDerivedToOwnedKeys( + start, + []sessConvergeAction{actionStampCanonicalIdentity}, + convergePredictedValues{canonicalInstanceName: "dir/agent-1"}, // singleton: empty slot + ) + if got := end[sessionpkg.CanonicalInstanceNameMetadata]; got != "dir/agent-1" { + t.Errorf("%s = %q, want %q", sessionpkg.CanonicalInstanceNameMetadata, got, "dir/agent-1") + } + if got := end[sessionpkg.CanonicalPoolSlotMetadata]; got != "" { + t.Errorf("%s = %q, want cleared (singleton heal must not keep a stale slot)", sessionpkg.CanonicalPoolSlotMetadata, got) + } + // The predicted end must read back as a singleton identity, not a pooled one. + if ci := sessionpkg.CanonicalIdentityFromMetadata(end); ci.PoolSlot != 0 { + t.Errorf("CanonicalIdentityFromMetadata(end).PoolSlot = %d, want 0 (singleton)", ci.PoolSlot) + } +} + +// TestConvergeShadowMarkSkipLeavesDenominatorOnce proves a session captured at +// loop entry and then skipped (a pre-probe early-continue tick) leaves the +// denominator with exactly ONE typed skip: the skip reason increments, the tick +// never counts as evaluated, and finish does NOT double-count it as capture_loss. +// The last part is the regression guard — markSkip must forget the session in the +// ordered set too, not just the eval map. +func TestConvergeShadowMarkSkipLeavesDenominatorOnce(t *testing.T) { + t.Setenv("GC_CONVERGE_SHADOW", "1") + counters := newConvergeShadowCounters() + tick := newConvergeShadowTick("observer-test", 1, fixtureNow, true, counters) + if tick == nil { + t.Fatal("newConvergeShadowTick returned nil with harness enabled") + } + const sid = "sess-1" + // Capture durable facts at loop entry (as the reconciler does), then skip the + // tick before any runtime probe. + tick.captureDurable(sid, "tok", "dir/agent-1", durableFacts{now: fixtureNow}, map[string]string{}, convergePredictedValues{}) + tick.markSkip(sid, skipEarlyContinue) + // finish must not resurrect the skipped session as capture-loss. + tick.finish(map[string]map[string]string{sid: {}}) + + snap := counters.snapshot() + if snap.SessionsSkipped[skipEarlyContinue] != 1 { + t.Fatalf("skipEarlyContinue = %d, want 1", snap.SessionsSkipped[skipEarlyContinue]) + } + if snap.SessionsSkipped[skipCaptureLoss] != 0 { + t.Fatalf("skipCaptureLoss = %d, want 0 (skipped tick was double-counted)", snap.SessionsSkipped[skipCaptureLoss]) + } + if snap.SessionsEvaluated != 0 { + t.Fatalf("SessionsEvaluated = %d, want 0 (a skipped tick is never evaluated)", snap.SessionsEvaluated) + } +} diff --git a/cmd/gc/session_converge_shadow_writesite_test.go b/cmd/gc/session_converge_shadow_writesite_test.go new file mode 100644 index 0000000000..78bba134ab --- /dev/null +++ b/cmd/gc/session_converge_shadow_writesite_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" +) + +// convergeComparedKeyWriteSiteInventory is the PERMANENT writer inventory the +// S19 Stage 3 double-write safety review demanded (3b/3c). Every non-test cmd/gc +// file that writes a compared metadata key MUST appear here, and every file here +// must be wired into the in-process recorder — either by calling +// recordLegacyCompareWrites directly, or (for pure map-builders with no bead ID) +// by carrying the `convergecompare:recorded-by-caller` marker documenting the +// caller that records on its behalf. +// +// Adding a new writer of a compared key without registering it here fails +// TestConvergeCompareKeyWriteSitesWired. This is the enforcement described in the +// plan: "no non-test cmd/gc code may write a compared metadata key except via the +// recording wrapper." +var convergeComparedKeyWriteSiteInventory = map[string]string{ + "session_identity.go": "desiredSessionIdentity builds the canonical stamp (pure); recorded by callers (adoptionBarrier.create, syncSessionBeads.create)", + "session_name_lookup.go": "pool-create canonical stamp; recorded via recordLegacyCompareWrites(poolSessionCreate)", + "session_reconcile.go": "healStatePatchWithRollback builds priming clears; recorded via recordLegacyCompareWrites(healStateWithRollback) at the ApplyPatch site", + "session_beads.go": "syncSessionBeads reclaim priming clears + create canonical stamp + named-session retire canonical clears; recorded via recordLegacyCompareWrites", + "session_lifecycle_parallel.go": "clearStaleResumeKeyMetadata priming clears; recorded via recordLegacyCompareWrites(clearStaleResumeKeyMetadata)", + "session_converge_shadow.go": "the recorder + owned-key oracle itself (applyDerivedToOwnedKeys writes a local prediction map, not a store)", +} + +// comparedKeyConstantNames are the metadata-key CONSTANT identifiers whose map +// assignment counts as a compared-key write, regardless of package qualifier. +var comparedKeyConstantNames = []string{ + "CanonicalInstanceNameMetadata", + "CanonicalPoolSlotMetadata", + "PrimedAtMetadataKey", + "PrimingAttemptedAtMetadataKey", + "PromptHashMetadataKey", +} + +// comparedKeyStringLiterals are the raw string values of the compared keys, in +// case a site writes the literal rather than the constant. +var comparedKeyStringLiterals = []string{ + `"canonical_instance_name"`, + `"canonical_pool_slot"`, + `"primed_at"`, + `"priming_attempted_at"`, + `"prompt_hash"`, +} + +// TestConvergeCompareKeyWriteSitesWired is the write-site-completeness guard, in +// the TestGCNonTestFilesStayOnWorkerBoundary style. It asserts: +// +// 1. every non-test cmd/gc file that writes a compared key is registered in the +// inventory (a new, unregistered writer fails the build); and +// 2. every registered writer is wired into the recorder (calls +// recordLegacyCompareWrites, or carries the recorded-by-caller marker); and +// 3. the inventory carries no stale entries (a file that no longer writes any +// compared key must be removed). +func TestConvergeCompareKeyWriteSitesWired(t *testing.T) { + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + dir := filepath.Dir(currentFile) + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir(%q): %v", dir, err) + } + + // A compared-key write is an assignment of the form `[] =` (not `==`). + // Build one matcher per key reference. + var writeMatchers []*regexp.Regexp + for _, name := range comparedKeyConstantNames { + // Index assignment: [session.PrimedAtMetadataKey] = / [PrimedAtMetadataKey] = + writeMatchers = append(writeMatchers, regexp.MustCompile(`\[[A-Za-z0-9_]*\.?`+regexp.QuoteMeta(name)+`\]\s*=[^=]`)) + // Map-literal key: sessionpkg.PrimedAtMetadataKey: (a write via composite literal) + writeMatchers = append(writeMatchers, regexp.MustCompile(`[A-Za-z0-9_]+\.`+regexp.QuoteMeta(name)+`\s*:`)) + } + for _, lit := range comparedKeyStringLiterals { + // Only the index-assignment literal form ["primed_at"] = is matched; a + // string-literal-as-map-key colon matcher would false-match doc comments + // like `// primedAt mirrors "primed_at": ...`. cmd/gc writes these keys via + // the exported constants, not string literals, so this stays a safety net. + writeMatchers = append(writeMatchers, regexp.MustCompile(`\[`+regexp.QuoteMeta(lit)+`\]\s*=[^=]`)) + } + + writersFound := map[string]bool{} + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatalf("ReadFile(%q): %v", name, err) + } + content := string(data) + writes := false + for _, m := range writeMatchers { + if m.MatchString(content) { + writes = true + break + } + } + if !writes { + continue + } + writersFound[name] = true + + if _, registered := convergeComparedKeyWriteSiteInventory[name]; !registered { + t.Errorf("%s writes a compared metadata key but is NOT registered in convergeComparedKeyWriteSiteInventory — register it and wire it into recordLegacyCompareWrites", name) + continue + } + if !strings.Contains(content, "recordLegacyCompareWrites") && + !strings.Contains(content, "convergecompare:recorded-by-caller") { + t.Errorf("%s is a registered compared-key writer but is not wired into the recorder (missing recordLegacyCompareWrites call or convergecompare:recorded-by-caller marker)", name) + } + } + + // Stale-entry guard: every inventory entry must still be a live writer. + for name := range convergeComparedKeyWriteSiteInventory { + if !writersFound[name] { + t.Errorf("inventory entry %q no longer writes a compared metadata key — remove the stale entry", name) + } + } +} diff --git a/cmd/gc/session_identity.go b/cmd/gc/session_identity.go index eef8175981..04784364d7 100644 --- a/cmd/gc/session_identity.go +++ b/cmd/gc/session_identity.go @@ -1,6 +1,10 @@ package main -import "strconv" +import ( + "strconv" + + "github.com/gastownhall/gascity/internal/session" +) // sessionIdentityInputs are the durable facts that determine a session bead's // canonical identity metadata. It is deliberately a flat value so the identity @@ -25,6 +29,11 @@ type sessionIdentityInputs struct { InstanceToken string // PoolSlot is the pool instance slot; 0 for singleton / non-pool sessions. PoolSlot int + // ConfigResolved reports that AgentName is a config-resolved identity, not an + // orphan fallback (e.g. adoption's agent_name = sessionName arm). It gates + // the durable canonical-identity record (S19 S2-3): a canonical record is + // stamped only from config-resolved identity, never from an orphan name. + ConfigResolved bool } // desiredSessionIdentity states the canonical session-identity contract once, @@ -36,6 +45,13 @@ type sessionIdentityInputs struct { // Keys are emitted only when meaningful: agent_name/session_name/pool_slot are // omitted when their inputs are zero so callers that assign those later (the // adoption barrier, pending pool creates) stay byte-identical. +// +// The durable canonical-identity record (S19 Stage 2, WRITE-ONLY) is stamped +// only when ConfigResolved AND AgentName is non-empty: canonical_instance_name +// mirrors agent_name and canonical_pool_slot mirrors pool_slot. The slot is +// coupled to the name (never stamped alone), and an orphan fallback name +// (ConfigResolved false) mints no record — a wrong authoritative identity is +// worse than an absent one (S2-3). func desiredSessionIdentity(in sessionIdentityInputs) map[string]string { meta := map[string]string{ "state": in.State, @@ -52,5 +68,14 @@ func desiredSessionIdentity(in sessionIdentityInputs) map[string]string { if in.PoolSlot > 0 { meta["pool_slot"] = strconv.Itoa(in.PoolSlot) } + if in.ConfigResolved && in.AgentName != "" { + // convergecompare:recorded-by-caller — desiredSessionIdentity is a pure + // builder with no bead ID; the S19 Stage 3 shadow recorder is fed by the + // callers that persist this map (adoptionBarrier.create, syncSessionBeads.create). + meta[session.CanonicalInstanceNameMetadata] = in.AgentName + if in.PoolSlot > 0 { + meta[session.CanonicalPoolSlotMetadata] = strconv.Itoa(in.PoolSlot) + } + } return meta } diff --git a/cmd/gc/session_identity_test.go b/cmd/gc/session_identity_test.go index 02a14d64f0..74712af204 100644 --- a/cmd/gc/session_identity_test.go +++ b/cmd/gc/session_identity_test.go @@ -84,6 +84,85 @@ func TestDesiredSessionIdentity(t *testing.T) { "instance_token": "t", }, }, + { + name: "config-resolved singleton stamps canonical name without slot", + in: sessionIdentityInputs{ + AgentName: "gastown/worker", + State: "active", + Generation: 1, + ContinuationEpoch: 1, + InstanceToken: "t", + ConfigResolved: true, + }, + want: map[string]string{ + "agent_name": "gastown/worker", + "state": "active", + "generation": "1", + "continuation_epoch": "1", + "instance_token": "t", + "canonical_instance_name": "gastown/worker", + }, + }, + { + name: "config-resolved pool instance stamps canonical name and slot", + in: sessionIdentityInputs{ + AgentName: "gastown/worker-3", + State: "active", + Generation: 1, + ContinuationEpoch: 1, + InstanceToken: "t", + PoolSlot: 3, + ConfigResolved: true, + }, + want: map[string]string{ + "agent_name": "gastown/worker-3", + "state": "active", + "generation": "1", + "continuation_epoch": "1", + "instance_token": "t", + "pool_slot": "3", + "canonical_instance_name": "gastown/worker-3", + "canonical_pool_slot": "3", + }, + }, + { + name: "orphan (not config-resolved) mints no canonical record", + in: sessionIdentityInputs{ + AgentName: "some-session", + State: "active", + Generation: 1, + ContinuationEpoch: 1, + InstanceToken: "t", + PoolSlot: 2, + ConfigResolved: false, + }, + want: map[string]string{ + "agent_name": "some-session", + "state": "active", + "generation": "1", + "continuation_epoch": "1", + "instance_token": "t", + "pool_slot": "2", + }, + }, + { + name: "config-resolved but empty agent name stamps no canonical record", + in: sessionIdentityInputs{ + SessionName: "city-worker", + State: "active", + Generation: 1, + ContinuationEpoch: 1, + InstanceToken: "t", + ConfigResolved: true, + }, + want: map[string]string{ + "session_name": "city-worker", + "state": "active", + "generation": "1", + "continuation_epoch": "1", + "instance_token": "t", + }, + }, } for _, tt := range tests { diff --git a/cmd/gc/session_lifecycle_parallel.go b/cmd/gc/session_lifecycle_parallel.go index 710be9f42a..da7376056d 100644 --- a/cmd/gc/session_lifecycle_parallel.go +++ b/cmd/gc/session_lifecycle_parallel.go @@ -193,6 +193,17 @@ type preparedStart struct { liveHash string provisionHash string launchHash string + // promptDelivered reports whether THIS incarnation actually delivers the + // rendered startup prompt (S19 confirmation signal 1). It is the pure + // promptDelivery decision AND-ed with the fresh-launch condition, i.e. the + // exact complement of the resume override below — so a resume that swaps in + // restartPromptNudge and re-sets GC_STARTUP_PROMPT_DELIVERED for hooks stamps + // no priming marker. promptHash is the sha256 of the rendered startup template + // prompt (tp.Prompt) only — it excludes the one-shot initial_message override + // appended to the delivered payload, so the stored hash still matches a later + // re-derivation from the template (S19 re-eligibility). + promptDelivered bool + promptHash string } type startResult struct { @@ -834,7 +845,7 @@ func buildPreparedStartWithWorkDirResolver( ) (*preparedStart, error) { session := candidate.session tp := candidate.tp - agentCfg := templateParamsToConfig(tp) + agentCfg, delivery := templateParamsToConfigWithDelivery(tp) // Apply template_overrides from bead metadata. These are per-session // schema option overrides (e.g., {"model":"opus","effort":"high"}) that @@ -964,6 +975,19 @@ func buildPreparedStartWithWorkDirResolver( agentCfg.Command = resolveSessionCommand(agentCfg.Command, sk, parentSID, tp.ResolvedProvider, firstStart, forceFresh) } hasResumeKey := strings.TrimSpace(session.Metadata["session_key"]) != "" + // S19 priming confirmation (write-only in Stage 2): a marker is stamped only + // when the pure delivery decision holds AND this incarnation is a fresh + // launch — the exact complement of the resume override below, which swaps in + // restartPromptNudge and delivers nothing. Reading the env marker instead + // would mis-stamp every resume (it is re-set to "1" for hook consumption). + promptDelivered := delivery.Delivered && (firstStart || forceFresh || !hasResumeKey) + // prompt_hash is the sha256 of the rendered startup TEMPLATE prompt (tp.Prompt) + // only, computed here BEFORE the one-shot initial_message is appended to the + // delivered payload below. The hash exists so a template/config change re-primes + // the session (S19 Stage 4); a fresh re-launch re-renders tp.Prompt but never + // replays the transient initial_message, so hashing the delivered bytes would + // make the stored hash never match the re-derivation and re-prime forever. + promptHash := sessionpkg.PromptHash(tp.Prompt) if !firstStart && !forceFresh && hasResumeKey { agentCfg.PromptSuffix = "" agentCfg.PromptFlag = "" @@ -1037,13 +1061,15 @@ func buildPreparedStartWithWorkDirResolver( } agentCfg = runtime.SyncWorkDirEnv(agentCfg) return &preparedStart{ - candidate: candidate, - cfg: agentCfg, - coreHash: coreHash, - coreBreakdown: coreBreakdown, - liveHash: liveHash, - provisionHash: provisionHash, - launchHash: launchHash, + candidate: candidate, + cfg: agentCfg, + coreHash: coreHash, + coreBreakdown: coreBreakdown, + liveHash: liveHash, + provisionHash: provisionHash, + launchHash: launchHash, + promptDelivered: promptDelivered, + promptHash: promptHash, }, nil } @@ -1821,9 +1847,17 @@ func clearStaleResumeKeyMetadata(session *beads.Bead, sessFront *sessionpkg.Stor "session_key": "", "started_config_hash": "", "continuation_reset_pending": "true", + // Priming markers share started_config_hash's lifetime (S19 Stage 2): + // this stale-resume clear forces a first start, so they reset with it. + sessionpkg.PrimedAtMetadataKey: "", + sessionpkg.PrimingAttemptedAtMetadataKey: "", + sessionpkg.PromptHashMetadataKey: "", } if sessFront != nil && strings.TrimSpace(session.ID) != "" { _ = sessFront.ApplyPatch(session.ID, patch) + // S19 Stage 3 shadow: record the legacy priming-marker clears (no-op + // unless the shadow harness is enabled). + recordLegacyCompareWrites(session.ID, "clearStaleResumeKeyMetadata", patch) } if session.Metadata == nil { session.Metadata = make(map[string]string, len(patch)) @@ -1891,6 +1925,16 @@ func commitStartResultTraced( // from observing a transient state where the claim is gone but the // post-create marker hasn't landed yet. See confirmPendingStart for // the state gate. + // S19 priming confirmation pair (write-only in Stage 2): stamped only when + // this incarnation delivered the rendered startup prompt. result.err == nil + // here, so "start succeeded" already holds — the (Delivered && start + // succeeded) signal. Zero values ⇒ CommitStartedPatch emits no priming keys. + primedAt := time.Time{} + promptHash := "" + if result.prepared.promptDelivered { + primedAt = clk.Now() + promptHash = result.prepared.promptHash + } metadata := sessionpkg.CommitStartedPatch(sessionpkg.CommitStartedPatchInput{ CoreHash: result.prepared.coreHash, LiveHash: result.prepared.liveHash, @@ -1904,6 +1948,8 @@ func commitStartResultTraced( // awake interval — stamp a fresh compute-usage epoch for it. StartsAwakeInterval: confirmPendingStart(session.Metadata["state"]), Now: clk.Now(), + PrimedAt: primedAt, + PromptHash: promptHash, }) storedMCPSnapshot, err := sessionpkg.EncodeMCPServersSnapshot(result.prepared.cfg.MCPServers) if err != nil { @@ -2108,6 +2154,18 @@ func recoverRunningPendingCreate( } else { now = time.Now() } + // S19 priming pair (write-only in Stage 2). The rebuild re-derives prepared + // from current durable state; a pre-commit crash left started_config_hash="", + // so firstStart is true and prepared.promptDelivered mirrors the original + // launch's delivery. If config changed since, promptHash describes the + // current rendered prompt — consistent with this site stamping current + // hashes. Zero values ⇒ no priming keys emitted. + primedAt := time.Time{} + promptHash := "" + if prepared.promptDelivered { + primedAt = now + promptHash = prepared.promptHash + } metadata := sessionpkg.CommitStartedPatch(sessionpkg.CommitStartedPatchInput{ CoreHash: prepared.coreHash, LiveHash: prepared.liveHash, @@ -2127,6 +2185,8 @@ func recoverRunningPendingCreate( // start only — not the StateAwake re-confirmation above. StartsAwakeInterval: confirmPendingStart(session.Metadata["state"]), Now: now, + PrimedAt: primedAt, + PromptHash: promptHash, }) if err := sessionFrontDoor(store).ApplyPatch(session.ID, metadata); err != nil { if trace != nil { diff --git a/cmd/gc/session_lifecycle_parallel_phase2_test.go b/cmd/gc/session_lifecycle_parallel_phase2_test.go index ab9151612d..0b37958c8b 100644 --- a/cmd/gc/session_lifecycle_parallel_phase2_test.go +++ b/cmd/gc/session_lifecycle_parallel_phase2_test.go @@ -9,6 +9,7 @@ import ( "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/clock" "github.com/gastownhall/gascity/internal/config" + sessionpkg "github.com/gastownhall/gascity/internal/session" workertest "github.com/gastownhall/gascity/internal/worker/workertest" ) @@ -106,6 +107,18 @@ func TestPhase2HookEnabledClaudeFirstTurnStartupPayload(t *testing.T) { if strings.Count(payload, "Do the first task.") != 1 { t.Fatalf("payload = %q, want initial_message exactly once", payload) } + + // prompt_hash pins the rendered startup TEMPLATE prompt only. Even though the + // delivered payload above carries the one-shot initial_message, the stored hash + // must exclude it so a later Stage-4 re-derivation from the template still + // matches (S19); hashing the delivered payload would re-prime the session + // forever. + if got, want := prepared.promptHash, sessionpkg.PromptHash("Base worker prompt"); got != want { + t.Errorf("promptHash = %q, want base-template hash %q (initial_message must be excluded)", got, want) + } + if prepared.promptHash == sessionpkg.PromptHash(payload) { + t.Errorf("promptHash must not hash the delivered payload %q (which includes initial_message)", payload) + } } func TestPhase2InputResultFailureClassification(t *testing.T) { diff --git a/cmd/gc/session_lifecycle_parallel_test.go b/cmd/gc/session_lifecycle_parallel_test.go index d28221f855..97ede30b45 100644 --- a/cmd/gc/session_lifecycle_parallel_test.go +++ b/cmd/gc/session_lifecycle_parallel_test.go @@ -4148,6 +4148,77 @@ func TestRecoverRunningPendingCreate_ReturnsMintedInstanceTokenForSnapshotFold(t } } +// TestRecoverRunningPendingCreate_StampsPrimingPairWhenDelivered pins the B2 +// write-only stamp (S19 Stage 2): the crash-recovery re-confirmation of an +// already-running runtime stamps the primed_at/prompt_hash confirmation pair +// when the rebuilt prepared start would have delivered the prompt (the +// pre-commit crash left started_config_hash="" so firstStart=true and +// promptDelivered mirrors the original launch), and stamps NOTHING for an empty +// prompt (the P5 gate). Nothing reads the pair in Stage 2 — this pins the write. +func TestRecoverRunningPendingCreate_StampsPrimingPairWhenDelivered(t *testing.T) { + const prompt = "do the work" + clkTime := time.Date(2026, 3, 18, 12, 0, 1, 0, time.UTC) + + newRecoveryBead := func(store *beads.MemStore) beads.Bead { + bead, err := store.Create(beads.Bead{ + Title: "helper", + Type: sessionBeadType, + Labels: []string{sessionBeadLabel}, + Metadata: map[string]string{ + "session_name": "sky", + "pending_create_claim": "true", + "state": "active", + "state_reason": "creation_complete", + // No started_config_hash — the pre-commit crash shape, so the + // rebuild classifies firstStart=true and mirrors delivery. + }, + }) + if err != nil { + t.Fatal(err) + } + return bead + } + cfg := &config.City{Agents: []config.Agent{{Name: "helper"}}} + + t.Run("delivered prompt stamps the pair", func(t *testing.T) { + store := beads.NewMemStore() + bead := newRecoveryBead(store) + tp := TemplateParams{SessionName: "sky", TemplateName: "helper", Command: "claude", Prompt: prompt} + if ok, _ := recoverRunningPendingCreate(&bead, tp, cfg, store, &clock.Fake{Time: clkTime}, nil); !ok { + t.Fatal("recoverRunningPendingCreate returned false, want true") + } + got, err := store.Get(bead.ID) + if err != nil { + t.Fatal(err) + } + if want := clkTime.UTC().Format(time.RFC3339); got.Metadata[sessionpkg.PrimedAtMetadataKey] != want { + t.Errorf("primed_at = %q, want %q", got.Metadata[sessionpkg.PrimedAtMetadataKey], want) + } + if want := sessionpkg.PromptHash(prompt); got.Metadata[sessionpkg.PromptHashMetadataKey] != want { + t.Errorf("prompt_hash = %q, want %q", got.Metadata[sessionpkg.PromptHashMetadataKey], want) + } + }) + + t.Run("empty prompt stamps nothing (P5)", func(t *testing.T) { + store := beads.NewMemStore() + bead := newRecoveryBead(store) + tp := TemplateParams{SessionName: "sky", TemplateName: "helper", Command: "claude", Prompt: ""} + if ok, _ := recoverRunningPendingCreate(&bead, tp, cfg, store, &clock.Fake{Time: clkTime}, nil); !ok { + t.Fatal("recoverRunningPendingCreate returned false, want true") + } + got, err := store.Get(bead.ID) + if err != nil { + t.Fatal(err) + } + if v := got.Metadata[sessionpkg.PrimedAtMetadataKey]; v != "" { + t.Errorf("primed_at = %q, want empty for empty prompt", v) + } + if v := got.Metadata[sessionpkg.PromptHashMetadataKey]; v != "" { + t.Errorf("prompt_hash = %q, want empty for empty prompt", v) + } + }) +} + // TestPendingCreateResidueFold_CarriesStaleResumeStartedConfigHashClear pins the // Step-5a fix: buildPreparedStart's stale-resume guard (clearStaleResumeKeyMetadata) // clears started_config_hash on the raw bead + store outside any folded batch. On the diff --git a/cmd/gc/session_name_lookup.go b/cmd/gc/session_name_lookup.go index 9b0aee09b1..479b88ef21 100644 --- a/cmd/gc/session_name_lookup.go +++ b/cmd/gc/session_name_lookup.go @@ -269,6 +269,16 @@ func createPoolSessionBeadWithAlias( } meta[key] = strings.TrimSpace(value) } + // Durable canonical-identity record (S19 Stage 2, WRITE-ONLY). Stamped AFTER + // the identity.Metadata copy so a caller-supplied metadata entry can never + // overwrite the config-resolved record — the canonical record is the one + // authoritative identity (S2-3 honesty). The identity here is pool-resolved + // config identity, so it is safe to stamp; agentName is non-empty. Slot is + // coupled to the name. + meta[sessionpkg.CanonicalInstanceNameMetadata] = agentName + if identity.Slot > 0 { + meta[sessionpkg.CanonicalPoolSlotMetadata] = strconv.Itoa(identity.Slot) + } beadID, err := sessionFrontDoor(store).CreateSession(sessionpkg.CreateSpec{ ID: explicitID, Title: title, @@ -278,6 +288,10 @@ func createPoolSessionBeadWithAlias( if err != nil { return beads.Bead{}, err } + // S19 Stage 3 shadow: record the legacy canonical-identity stamp on the + // pool-create path now that the bead ID exists (no-op unless the shadow + // harness is enabled). + recordLegacyCompareWrites(beadID, "poolSessionCreate", meta) bead, err := store.Get(beadID) if err != nil { return beads.Bead{}, err diff --git a/cmd/gc/session_reconcile.go b/cmd/gc/session_reconcile.go index 2fbb5e47a6..35e7c29049 100644 --- a/cmd/gc/session_reconcile.go +++ b/cmd/gc/session_reconcile.go @@ -944,6 +944,11 @@ func healStateWithRollback(session *beads.Bead, alive bool, sessFront *sessionpk for k, v := range batch { session.Metadata[k] = v } + // S19 Stage 3 shadow: record the legacy compared-key writes this heal ACTUALLY + // applied (no-op unless the shadow harness is enabled). Colocated with the + // ApplyPatch + in-memory mirror so a pure builder (healStatePatch) invoked only + // for inspection never records a write that never happened. + recordLegacyCompareWrites(session.ID, "healStateWithRollback", batch) return batch } @@ -1047,6 +1052,11 @@ func healStatePatchWithRollback(session beads.Bead, alive bool, clk clock.Clock, batch["session_key"] = "" batch["started_config_hash"] = "" batch["continuation_reset_pending"] = "true" + // Priming markers share started_config_hash's lifetime (S19 + // Stage 2): this asleep continuation reset re-primes. + batch[sessionpkg.PrimedAtMetadataKey] = "" + batch[sessionpkg.PrimingAttemptedAtMetadataKey] = "" + batch[sessionpkg.PromptHashMetadataKey] = "" } } } diff --git a/cmd/gc/session_reconcile_test.go b/cmd/gc/session_reconcile_test.go index 7617431d49..56d3c4eeb0 100644 --- a/cmd/gc/session_reconcile_test.go +++ b/cmd/gc/session_reconcile_test.go @@ -1825,6 +1825,11 @@ func TestHealStatePatchProjectsRuntimeLiveness(t *testing.T) { "session_key": "", "started_config_hash": "", "continuation_reset_pending": "true", + // Priming markers share started_config_hash's lifetime (S19 + // Stage 2 C-6): the continuation reset clears them too. + sessionpkg.PrimedAtMetadataKey: "", + sessionpkg.PrimingAttemptedAtMetadataKey: "", + sessionpkg.PromptHashMetadataKey: "", }, }, { @@ -1906,6 +1911,11 @@ func TestHealStatePatchProjectsRuntimeLiveness(t *testing.T) { "continuation_reset_pending": "true", "pending_create_claim": "", "pending_create_started_at": "", + // Priming markers share started_config_hash's lifetime (S19 + // Stage 2 C-6): the continuation reset clears them too. + sessionpkg.PrimedAtMetadataKey: "", + sessionpkg.PrimingAttemptedAtMetadataKey: "", + sessionpkg.PromptHashMetadataKey: "", }, }, } diff --git a/cmd/gc/session_reconciler.go b/cmd/gc/session_reconciler.go index eb1c08ce5a..30e083a47f 100644 --- a/cmd/gc/session_reconciler.go +++ b/cmd/gc/session_reconciler.go @@ -1492,6 +1492,24 @@ func reconcileSessionBeadsTracedWithNamedDemand( tick := newReconcileTick(ordered) infoByID := tick.infoByID orderedIDs := tick.orderedIDs + // S19 Stage 3 shadow harness (OBSERVATION-ONLY): assemble the per-tick + // collector from the ALREADY-observed coherent Info snapshot + raw beads — + // no new probes, no writes. shadowTick is nil (and every method a no-op) + // unless GC_CONVERGE_SHADOW is set, so this reconciler is byte-identical when + // the harness is off. The deferred detach handles the loop's early returns. + var shadowTick *convergeShadowTick + var shadowStartSnaps map[string]map[string]string + if convergeShadowEnabled() { + shadowTick = newConvergeShadowTick(cityName, nextConvergeShadowTickSeq(), clk.Now().UTC(), true, convergeShadowMetrics) + // Safety-net detach for the loop's early returns; idempotent with the detach + // finish already runs, and ownership-guarded so a concurrent city tick's live + // recorder is never cleared here. + defer shadowTick.detach() + shadowStartSnaps = make(map[string]map[string]string, len(ordered)) + for i := range ordered { + shadowStartSnaps[ordered[i].ID] = snapshotComparedKeys(ordered[i].Metadata) + } + } // Phase 1: Forward pass (topo order) — wake sessions, handle alive state. var startCandidates []startCandidate var wakeTargets []wakeTarget @@ -1552,6 +1570,20 @@ func reconcileSessionBeadsTracedWithNamedDemand( info := infoByID[session.ID] name := strings.TrimSpace(info.SessionNameMetadata) tp, desired := desiredState[name] + if shadowTick != nil { + // 3a: durable facts from already-observed Info + raw priming metadata. + // The predicted canonical value is a best-effort heal proxy; it is only + // consulted by the C4 value-parity check when legacy also wrote the key + // this tick, which this reconciler pass never does (identity is stamped + // at create/adopt), so it can never manufacture a false divergence here. + shadowTick.captureDurable(session.ID, info.InstanceToken, name, + buildDurableFactsFromInfo(info, session.Metadata, shadowTick.tickNow), + shadowStartSnaps[session.ID], + convergePredictedValues{ + canonicalInstanceName: strings.TrimSpace(info.AgentName), + canonicalPoolSlot: strings.TrimSpace(info.PoolSlot), + }) + } if _, _, pending := resetPendingCommittedAtInfo(info); !pending && dt != nil { dt.clearResetStall(session.ID) } @@ -1574,6 +1606,14 @@ func reconcileSessionBeadsTracedWithNamedDemand( // snapshot, no *session mutation before the finalize call). Guarded by // TestReconcileSessionBeads_MinFloorCountReflectsMidTickCloseDrainAck. tick.applyResult(session.ID, result) + if shadowTick != nil { + // Pre-probe early-continue (drain-ack): nothing was compared this tick, + // so leave the denominator with a typed skip. Without this the session + // would carry its loop-entry durable capture but no runtime probe into + // finish and inflate sessions_evaluated with an unproven "clean" + // (hardening 2). + shadowTick.markSkip(session.ID, skipEarlyContinue) + } continue } @@ -1587,6 +1627,12 @@ func reconcileSessionBeadsTracedWithNamedDemand( "state": info.MetadataState, }) } + if shadowTick != nil { + // Pre-probe early-continue (unknown state): forward-compat skip with + // nothing to compare — leave the denominator with a typed skip + // (hardening 2). + shadowTick.markSkip(session.ID, skipEarlyContinue) + } continue } // Back in a known state: drop any stale unknown-state throttle markers so a @@ -1602,6 +1648,12 @@ func reconcileSessionBeadsTracedWithNamedDemand( if livenessErr != nil { providerAlive = false } + if shadowTick != nil { + // 3a: capture the !desired path's OWN probe result (presence only, + // by bead ID). alive is unknown on this path; probe target is left + // empty because this path probes by ID, not name (no name to skew). + shadowTick.captureRuntime(session.ID, "workerSessionTargetRunningWithConfig", "", triFromBool(providerAlive), convergeTriUnknown) + } // Run this before configured named-session preservation. A stale // state=creating bead with an expired pending-create lease would // otherwise stay open and keep holding its alias forever. @@ -2093,6 +2145,11 @@ func reconcileSessionBeadsTracedWithNamedDemand( // The desired-session fast path only needs running/alive; attachment // and activity are probed by the narrower branches that use them. running, alive := observeRuntimeProviderLiveness(sp, name, tp.Hints.ProcessNames) + if shadowTick != nil { + // 3a: capture the desired fast path's OWN two-bit probe (present + + // alive) by name, enabling zombie (present && !alive) expression. + shadowTick.captureRuntime(session.ID, "observeRuntimeProviderLiveness", name, triFromBool(running), triFromBool(alive)) + } peek := cachedSessionPeek(cityPath, store, sp, cfg, session.ID, tp.Hints.ProcessNames) recordResetStallIfDue(*session, tp.TemplateName, name, alive, startupTimeout, clk.Now().UTC(), dt, rec, stderr, trace) @@ -3170,6 +3227,21 @@ func reconcileSessionBeadsTracedWithNamedDemand( wakeTargets = append(wakeTargets, wakeTarget{session: session, tp: tp, alive: alive}) } + if shadowTick != nil { + // 3b/3c: snapshot the compared keys at tick end from the raw beads (kept in + // lockstep with the reconciler's in-memory mutations), then run the oracle + + // replay comparator and update the counters. Pure observation — no writes. + endSnaps := make(map[string]map[string]string, len(ordered)) + for i := range ordered { + endSnaps[ordered[i].ID] = snapshotComparedKeys(ordered[i].Metadata) + } + shadowTick.finish(endSnaps) + // Operator read path (Q3: no new event type): surface the soak signal on the + // reconciler's existing stderr channel — one bounded line per enabled tick, + // so a live GC_CONVERGE_SHADOW soak reports its denominator and surviving + // divergences instead of incrementing counters nothing can read. + fmt.Fprintf(stderr, "session reconciler: %s\n", convergeShadowMetrics.snapshot().operatorSummary()) //nolint:errcheck // best-effort operator log + } recordPhase(TraceSiteSessionReconcileForwardPass, "session_reconcile.forward_pass", phaseStart, map[string]any{ "ordered_session_count": len(ordered), "wake_target_count": len(wakeTargets), diff --git a/cmd/gc/session_wake_test.go b/cmd/gc/session_wake_test.go index 42a5a71d89..dbed927360 100644 --- a/cmd/gc/session_wake_test.go +++ b/cmd/gc/session_wake_test.go @@ -305,6 +305,11 @@ func TestPreWakeCommit_FreshModeTraceLogsClearedProviderMetadata(t *testing.T) { "started_live_hash": "old-live-hash", "live_hash": "old-live-hash", "startup_dialog_verified": "true", + // Priming markers share the fresh-wake reset (S19 Stage 2); set them + // so the trace log lists them among the cleared keys. + "primed_at": "2026-03-08T11:00:00Z", + "priming_attempted_at": "2026-03-08T11:00:00Z", + "prompt_hash": "abc123", }, }) if err != nil { diff --git a/cmd/gc/template_resolve.go b/cmd/gc/template_resolve.go index affe7b1f4a..d13f5080d0 100644 --- a/cmd/gc/template_resolve.go +++ b/cmd/gc/template_resolve.go @@ -778,6 +778,19 @@ func sessionBackendEnvWithError(cityPath, rigRoot string, rigs []config.Rig) (ma // launch or nudge path, it marks the runtime env so SessionStart hooks can add // context without repeating the full startup prompt. func templateParamsToConfig(tp TemplateParams) runtime.Config { + cfg, _ := templateParamsToConfigWithDelivery(tp) + return cfg +} + +// templateParamsToConfigWithDelivery is templateParamsToConfig plus the pure +// promptDelivery result it computed. The launch path (buildPreparedStart) needs +// the Delivered decision to stamp the S19 priming markers, but it must NOT infer +// delivery from cfg.Env[GC_STARTUP_PROMPT_DELIVERED]: the resume override in +// buildPreparedStartWithWorkDirResolver re-sets that env marker to "1" for hook +// consumption even when nothing is delivered that incarnation. Threading the +// result avoids that trap. templateParamsToConfig is the wrapper that discards +// the second value; all other call sites are unchanged. +func templateParamsToConfigWithDelivery(tp TemplateParams) (runtime.Config, promptDeliveryResult) { // SessionStart hooks can enrich context, but the startup prompt still needs // a first-turn delivery mechanism. Without argv/flag/nudge delivery, freshly // spawned workers sit idle at the provider prompt. The routing policy lives @@ -824,7 +837,7 @@ func templateParamsToConfig(tp TemplateParams) runtime.Config { // Ephemeral pool agents are likewise mouse-off (controller-poll safety). cfg.MouseOn = tp.Hints.MouseOn || templateParamsSessionOrigin(tp) == "manual" applyT3BridgeRuntimeConfig(tp, env) - return cfg + return cfg, delivery } func prependStartupPromptToNudge(prompt, nudge string) string { diff --git a/internal/session/canonical_identity.go b/internal/session/canonical_identity.go new file mode 100644 index 0000000000..cb1eb3ee6a --- /dev/null +++ b/internal/session/canonical_identity.go @@ -0,0 +1,103 @@ +package session + +import ( + "strconv" + "strings" +) + +const ( + // CanonicalInstanceNameMetadata is the durable metadata key holding a + // session's canonical qualified instance name — the one identity record the + // reconciler resolves and stamps at create/adoption time and (from S19 + // Stage 3 on) heals on later ticks. + // + // It is the level-triggered replacement (S19) for re-deriving identity every + // tick from up to six competing metadata/label sources through the precedence + // ladders in cmd/gc. When the record is present every read collapses to one + // field read; the config-derived ladder is consulted only to heal an absent + // record. Stage 2 is WRITE-ONLY: this key is stamped but no decision path + // reads it yet (the reader cutover is Stage 5). + CanonicalInstanceNameMetadata = "canonical_instance_name" + // CanonicalPoolSlotMetadata is the durable metadata key holding a session's + // canonical pool slot (a positive integer; absent/empty/<=0 means unslotted, + // i.e. a singleton). It is written and read alongside CanonicalInstanceNameMetadata. + CanonicalPoolSlotMetadata = "canonical_pool_slot" +) + +// freeCanonicalIdentityMetadata clears both durable canonical-identity keys on a +// metadata patch/update map (empty values clear at the store layer). Every +// named-session retirement path routes through this one helper so the two keys +// are always freed together and the "canonical identity is freed on retirement" +// invariant (S19) cannot drift between the RetireNamedSessionPatch builder and +// the hand-rolled Manager.Close configured-named-session path. +func freeCanonicalIdentityMetadata(meta map[string]string) { + meta[CanonicalInstanceNameMetadata] = "" + meta[CanonicalPoolSlotMetadata] = "" +} + +// CanonicalIdentity is the single durable identity record for a session bead: +// the canonical qualified instance name plus pool slot the reconciler resolved +// once and stamped, rather than a value re-inferred from competing sources each +// tick. Present reports whether a record was actually persisted; when it is +// false the caller falls back to the quarantined legacy config-derivation +// (from Stage 5) exactly once and then heals the record, so subsequent ticks +// read the field directly and every arrival path agrees by construction. +type CanonicalIdentity struct { + // QualifiedInstanceName is the canonical "dir/name" (or singleton) identity. + QualifiedInstanceName string + // PoolSlot is the canonical pool slot; 0 means unslotted (singleton). + PoolSlot int + // Present is true iff a canonical record was persisted (a non-empty + // qualified instance name is the record's existence signal). + Present bool +} + +// CanonicalIdentityFromMetadata reads the persisted canonical identity record +// from raw session-bead metadata. It reads exactly the two canonical keys and +// performs no config-derivation or precedence laddering — when the record is +// present it is authoritative. The record exists iff a non-empty canonical +// qualified instance name was stamped; an empty name yields the zero record +// (Present false) regardless of any stray slot value, because a canonical +// identity is meaningless without its name. +func CanonicalIdentityFromMetadata(meta map[string]string) CanonicalIdentity { + if meta == nil { + return CanonicalIdentity{} + } + return canonicalIdentityFrom(meta[CanonicalInstanceNameMetadata], meta[CanonicalPoolSlotMetadata]) +} + +// canonicalIdentityFrom is the single record-existence + slot-parse rule shared +// by CanonicalIdentityFromMetadata (over a raw bead map) and Info.CanonicalIdentity +// (over the two verbatim Info mirrors), so the two projections can never drift. +func canonicalIdentityFrom(rawName, rawSlot string) CanonicalIdentity { + name := strings.TrimSpace(rawName) + if name == "" { + return CanonicalIdentity{} + } + return CanonicalIdentity{ + QualifiedInstanceName: name, + PoolSlot: parseCanonicalSlot(rawSlot), + Present: true, + } +} + +// parseCanonicalSlot parses a canonical pool-slot metadata value. A missing, +// non-numeric, or non-positive value is unslotted (0). +func parseCanonicalSlot(raw string) int { + if v := strings.TrimSpace(raw); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return n + } + } + return 0 +} + +// CanonicalIdentity projects the canonical identity record from a session +// Info's two verbatim raw mirrors. It is a pure accessor over the mirrors — +// nothing is stored derived — so a folded ApplyPatch snapshot and a full +// re-projection agree by construction (TestInfoApplyPatchMatchesReprojection). +// Stage 2 is WRITE-ONLY: this accessor is computed but consulted by nothing +// outside tests. +func (i Info) CanonicalIdentity() CanonicalIdentity { + return canonicalIdentityFrom(i.CanonicalInstanceNameMetadata, i.CanonicalPoolSlotMetadata) +} diff --git a/internal/session/canonical_identity_test.go b/internal/session/canonical_identity_test.go new file mode 100644 index 0000000000..b0070b5c0c --- /dev/null +++ b/internal/session/canonical_identity_test.go @@ -0,0 +1,106 @@ +package session + +import ( + "reflect" + "testing" + + "github.com/gastownhall/gascity/internal/beads" +) + +func TestCanonicalIdentityFromMetadata(t *testing.T) { + cases := []struct { + name string + meta map[string]string + want CanonicalIdentity + }{ + { + name: "nil metadata is absent", + meta: nil, + want: CanonicalIdentity{}, + }, + { + name: "empty metadata is absent", + meta: map[string]string{}, + want: CanonicalIdentity{}, + }, + { + name: "name with positive slot", + meta: map[string]string{ + CanonicalInstanceNameMetadata: "dir/agent-1", + CanonicalPoolSlotMetadata: "3", + }, + want: CanonicalIdentity{QualifiedInstanceName: "dir/agent-1", PoolSlot: 3, Present: true}, + }, + { + name: "name without slot is unslotted singleton", + meta: map[string]string{CanonicalInstanceNameMetadata: "solo"}, + want: CanonicalIdentity{QualifiedInstanceName: "solo", PoolSlot: 0, Present: true}, + }, + { + name: "name and slot trimmed", + meta: map[string]string{CanonicalInstanceNameMetadata: " dir/a ", CanonicalPoolSlotMetadata: " 2 "}, + want: CanonicalIdentity{QualifiedInstanceName: "dir/a", PoolSlot: 2, Present: true}, + }, + { + name: "whitespace-only name is absent", + meta: map[string]string{CanonicalInstanceNameMetadata: " ", CanonicalPoolSlotMetadata: "2"}, + want: CanonicalIdentity{}, + }, + { + name: "slot without name is absent", + meta: map[string]string{CanonicalPoolSlotMetadata: "4"}, + want: CanonicalIdentity{}, + }, + { + name: "non-numeric slot is unslotted", + meta: map[string]string{CanonicalInstanceNameMetadata: "a", CanonicalPoolSlotMetadata: "xyz"}, + want: CanonicalIdentity{QualifiedInstanceName: "a", PoolSlot: 0, Present: true}, + }, + { + name: "zero slot is unslotted", + meta: map[string]string{CanonicalInstanceNameMetadata: "a", CanonicalPoolSlotMetadata: "0"}, + want: CanonicalIdentity{QualifiedInstanceName: "a", PoolSlot: 0, Present: true}, + }, + { + name: "negative slot is unslotted", + meta: map[string]string{CanonicalInstanceNameMetadata: "a", CanonicalPoolSlotMetadata: "-1"}, + want: CanonicalIdentity{QualifiedInstanceName: "a", PoolSlot: 0, Present: true}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := CanonicalIdentityFromMetadata(tc.meta); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("CanonicalIdentityFromMetadata(%v) = %+v, want %+v", tc.meta, got, tc.want) + } + }) + } +} + +// TestInfoCanonicalIdentityAccessor proves InfoFromPersistedBead mirrors the two +// canonical keys verbatim and that the Info.CanonicalIdentity() accessor equals +// CanonicalIdentityFromMetadata for every bead — the shared-helper drift guard +// that keeps the two projections identical (S2-6). +func TestInfoCanonicalIdentityAccessor(t *testing.T) { + metas := []map[string]string{ + nil, + {}, + {CanonicalInstanceNameMetadata: "dir/agent-2", CanonicalPoolSlotMetadata: "5"}, + {CanonicalInstanceNameMetadata: "solo"}, + {CanonicalInstanceNameMetadata: " dir/a ", CanonicalPoolSlotMetadata: " 2 "}, + {CanonicalPoolSlotMetadata: "4"}, // stray slot, no name + {CanonicalInstanceNameMetadata: "a", CanonicalPoolSlotMetadata: "garbage"}, + } + for _, meta := range metas { + b := beads.Bead{ID: "s", Type: "gc:session", Status: "open", Labels: []string{"gc:session"}, Metadata: meta} + info := InfoFromPersistedBead(b) + if got, want := info.CanonicalInstanceNameMetadata, meta[CanonicalInstanceNameMetadata]; got != want { + t.Errorf("meta=%v: mirror CanonicalInstanceNameMetadata = %q, want %q", meta, got, want) + } + if got, want := info.CanonicalPoolSlotMetadata, meta[CanonicalPoolSlotMetadata]; got != want { + t.Errorf("meta=%v: mirror CanonicalPoolSlotMetadata = %q, want %q", meta, got, want) + } + if got, want := info.CanonicalIdentity(), CanonicalIdentityFromMetadata(meta); !reflect.DeepEqual(got, want) { + t.Errorf("meta=%v: accessor = %+v, want %+v (drift between accessor and CanonicalIdentityFromMetadata)", meta, got, want) + } + } +} diff --git a/internal/session/chat.go b/internal/session/chat.go index e24377aead..3f9b4d68aa 100644 --- a/internal/session/chat.go +++ b/internal/session/chat.go @@ -148,12 +148,22 @@ func (m *Manager) clearStaleResumeMetadata(id string, b *beads.Bead) error { if err := m.store.SetMetadata(id, "continuation_reset_pending", "true"); err != nil { return fmt.Errorf("clearing stale resume metadata continuation_reset_pending: %w", err) } + // Priming markers share started_config_hash's lifetime (S19 Stage 2): this + // stale-resume clear forces a fresh start, so the markers reset with it. + for _, k := range primingResetKeys { + if err := m.store.SetMetadata(id, k, ""); err != nil { + return fmt.Errorf("clearing stale resume metadata %s: %w", k, err) + } + } if b.Metadata == nil { b.Metadata = make(map[string]string) } b.Metadata["session_key"] = "" b.Metadata["started_config_hash"] = "" b.Metadata["continuation_reset_pending"] = "true" + for _, k := range primingResetKeys { + b.Metadata[k] = "" + } return nil } diff --git a/internal/session/info_apply_patch_test.go b/internal/session/info_apply_patch_test.go index c308914482..4be60d8a91 100644 --- a/internal/session/info_apply_patch_test.go +++ b/internal/session/info_apply_patch_test.go @@ -42,6 +42,7 @@ var allProjectedMetadataKeys = []string{ "session_name_explicit", "wake_request", "restart_requested", "session_id_flag", "template_overrides", "wake_attempts", MetadataLastNudgeDeliveredAt, "provider_kind", + CanonicalInstanceNameMetadata, CanonicalPoolSlotMetadata, } // oracleBaseBeads returns diverse session beads: a fully-populated open bead, the @@ -81,6 +82,7 @@ func oracleBaseBeads() []beads.Bead { "session_name_explicit": "true", "wake_request": "explicit", "restart_requested": "true", "session_id_flag": "--session-id", "template_overrides": `{"x":"y"}`, "wake_attempts": "3", MetadataLastNudgeDeliveredAt: "2026-01-09T00:00:00Z", "provider_kind": "claude", + CanonicalInstanceNameMetadata: "dir/agent-1", CanonicalPoolSlotMetadata: "2", } clone := func(m map[string]string) map[string]string { out := make(map[string]string, len(m)) @@ -147,8 +149,13 @@ func oraclePatches() []MetadataPatch { {"pending_create_claim": " true "}, // untrimmed mirror vs trimmed bool {"manual_session": "1"}, {"session_drainable": "true"}, - {"live_hash": "ignored"}, // unknown key: must not change Info - {"startup_dialog_verified": "z"}, // unknown key + {CanonicalInstanceNameMetadata: "dir/renamed"}, // canonical name reset + {CanonicalInstanceNameMetadata: ""}, // canonical name cleared (record vanishes) + {CanonicalPoolSlotMetadata: ""}, // slot cleared, name kept + {CanonicalPoolSlotMetadata: "garbage"}, // non-numeric slot + {CanonicalInstanceNameMetadata: "", CanonicalPoolSlotMetadata: "4"}, // stray slot without name + {"live_hash": "ignored"}, // unknown key: must not change Info + {"startup_dialog_verified": "z"}, // unknown key {"state": "idle", "session_name": "", "provider": "codex", "wake_attempts": "9", "held_until": ""}, // multi-key mix } return append(patches, edge...) diff --git a/internal/session/info_codec.go b/internal/session/info_codec.go index 3b7804d772..6e8e4d5aea 100644 --- a/internal/session/info_codec.go +++ b/internal/session/info_codec.go @@ -101,6 +101,14 @@ var infoKeyCodec = []infoKeySpec{ i.ManualSession = strings.TrimSpace(v) == "true" i.ManualSessionMetadata = v }}, + + // Canonical-identity record mirrors (verbatim). The typed record is derived + // on demand via Info.CanonicalIdentity(); these keep the raw values so the + // fold copies them per-key. S19 Stage 2 is WRITE-ONLY: stamped at + // create/adoption but read by no decision path yet. + {CanonicalInstanceNameMetadata, func(i *Info, v string) { i.CanonicalInstanceNameMetadata = v }}, + {CanonicalPoolSlotMetadata, func(i *Info, v string) { i.CanonicalPoolSlotMetadata = v }}, + {MCPIdentityMetadataKey, func(i *Info, v string) { i.MCPIdentity = v }}, {MCPServersSnapshotMetadataKey, func(i *Info, v string) { i.MCPServersSnapshot = v }}, diff --git a/internal/session/info_codec_test.go b/internal/session/info_codec_test.go index 5a255c2400..b109c39105 100644 --- a/internal/session/info_codec_test.go +++ b/internal/session/info_codec_test.go @@ -65,8 +65,12 @@ func infoFromPersistedBeadFrozen(b beads.Bead) Info { ManualSession: strings.TrimSpace(b.Metadata["manual_session"]) == "true", ManualSessionMetadata: b.Metadata["manual_session"], Labels: b.Labels, - MCPIdentity: b.Metadata[MCPIdentityMetadataKey], - MCPServersSnapshot: b.Metadata[MCPServersSnapshotMetadataKey], + + // Canonical-identity record mirrors (verbatim). S19 Stage 2 (write-only). + CanonicalInstanceNameMetadata: b.Metadata[CanonicalInstanceNameMetadata], + CanonicalPoolSlotMetadata: b.Metadata[CanonicalPoolSlotMetadata], + MCPIdentity: b.Metadata[MCPIdentityMetadataKey], + MCPServersSnapshot: b.Metadata[MCPServersSnapshotMetadataKey], ProviderTerminalError: b.Metadata["provider_terminal_error"], HealthState: b.Metadata["session_health"], diff --git a/internal/session/lifecycle_exits.go b/internal/session/lifecycle_exits.go index 6f8b62567d..e4c9ef488a 100644 --- a/internal/session/lifecycle_exits.go +++ b/internal/session/lifecycle_exits.go @@ -188,6 +188,9 @@ func ConversationResetPatch(clearStartedConfigHash bool) MetadataPatch { } if clearStartedConfigHash { patch["started_config_hash"] = "" + // Priming markers share started_config_hash's lifetime (S19 Stage 2): a + // wake failure re-primes; a churn keeps the hash and its markers. + clearPrimingMarkers(patch) } return patch } diff --git a/internal/session/lifecycle_exits_test.go b/internal/session/lifecycle_exits_test.go index 616ea0e67c..1dc39683b1 100644 --- a/internal/session/lifecycle_exits_test.go +++ b/internal/session/lifecycle_exits_test.go @@ -284,6 +284,10 @@ func TestConversationResetPatch(t *testing.T) { "session_key": "", "started_config_hash": "", "continuation_reset_pending": "true", + // Priming markers share started_config_hash's lifetime (S19 Stage 2). + "primed_at": "", + "priming_attempted_at": "", + "prompt_hash": "", }) assertPatch(t, ConversationResetPatch(false), MetadataPatch{ "session_key": "", diff --git a/internal/session/lifecycle_transition.go b/internal/session/lifecycle_transition.go index f1b4bbf818..ea73a48a02 100644 --- a/internal/session/lifecycle_transition.go +++ b/internal/session/lifecycle_transition.go @@ -1,10 +1,69 @@ package session import ( + "crypto/sha256" + "encoding/hex" "fmt" "time" ) +// Priming markers record that a session's launch path delivered the rendered +// startup prompt (S19 §2 confirmation signal 1). They share the exact lifetime +// of started_config_hash: written only by CommitStartedPatch (both-or-neither, +// launch-confirmed) and cleared at every started_config_hash clear site, so a +// fresh incarnation re-primes and a resumed/churned incarnation keeps its +// markers. S19 Stage 2 is WRITE-ONLY: they are stamped/cleared but read by no +// decision path (Stage 3 shadows them, Stage 4 acts on them). +const ( + // PrimedAtMetadataKey records when the startup prompt was confirmed + // delivered (RFC3339). Written only by CommitStartedPatch (and, from Stage 4, + // the post-Nudge stamp) — never a write-ahead attempt marker. + PrimedAtMetadataKey = "primed_at" + // PrimingAttemptedAtMetadataKey is the write-ahead attempt marker. Defined + // (constant + clear sites) in Stage 2 but NEVER written here; its writer is + // the Stage-4 awake-scan path. + PrimingAttemptedAtMetadataKey = "priming_attempted_at" + // PromptHashMetadataKey records the sha256 of the rendered startup *template* + // prompt (tp.Prompt), so a later hash mismatch — the template/config the + // session would be re-launched with changed — marks the session re-eligible. + // It deliberately excludes the one-shot initial_message override, which is + // appended to the delivered payload only on a first start / fresh wake and is + // never replayed on a later re-launch; folding it in would make the stored + // hash never match a re-derivation from the template, re-priming forever. + PromptHashMetadataKey = "prompt_hash" +) + +// primingResetKeys are the three priming markers cleared wherever +// started_config_hash is cleared (S19 Stage 2 priming-key lifetime rule). Kept +// as a slice so the six clear sites share one vocabulary. +var primingResetKeys = []string{ + PrimedAtMetadataKey, + PrimingAttemptedAtMetadataKey, + PromptHashMetadataKey, +} + +// clearPrimingMarkers clears the three priming markers on a patch. Clearing a +// key that was never set is a no-op at the store layer (empty values clear), so +// this is behavior-preserving in a write-only stage. +func clearPrimingMarkers(patch MetadataPatch) { + for _, k := range primingResetKeys { + patch[k] = "" + } +} + +// PromptHash returns the sha256 hex digest of the exact rendered startup +// prompt. The empty prompt hashes to "" (not the sha256 of the empty string), +// so it is one of the two independent gates — alongside promptDelivery("") +// being undelivered — that keep an empty prompt from ever stamping a priming +// marker (S19 P5). +func PromptHash(prompt string) string { + if prompt == "" { + return "" + } + sum := sha256.Sum256([]byte(prompt)) + return hex.EncodeToString(sum[:]) +} + // CurrentBeadIDKey records the work bead a session is currently processing. // The reconciler writes it whenever a session is brought up for a specific // work bead. ComputeAwakeSet uses it to detect when an alive session has been @@ -18,6 +77,12 @@ var freshWakeConversationResetKeys = []string{ "started_live_hash", "live_hash", startupDialogVerifiedKey, + // Priming markers share started_config_hash's lifetime (S19 Stage 2): a + // fresh wake re-primes. This list and applyFreshWakeConversationReset must + // stay aligned — TestFreshWakeResetKeysAlignWithApply enforces it. + PrimedAtMetadataKey, + PrimingAttemptedAtMetadataKey, + PromptHashMetadataKey, } // ResetCommittedAtKey records when a restart handoff durably committed. @@ -56,6 +121,7 @@ func applyFreshWakeConversationReset(patch MetadataPatch) { patch["started_live_hash"] = "" patch["live_hash"] = "" patch[startupDialogVerifiedKey] = "" + clearPrimingMarkers(patch) } func pendingCreateStartedAt(now time.Time) string { @@ -255,6 +321,14 @@ type CommitStartedPatchInput struct { // (gastownhall/gascity#3513). StartsAwakeInterval bool Now time.Time + // PrimedAt, when non-zero and PromptHash is non-empty, records that this + // start's launch path delivered the rendered startup prompt (S19 §2 + // confirmation signal 1). Emitted atomically with started_config_hash so + // priming inherits the start path's crash semantics. Zero PrimedAt (or an + // empty PromptHash) ⇒ no priming keys, so a resume/recovery that delivered + // nothing stamps nothing. priming_attempted_at is never emitted here. + PrimedAt time.Time + PromptHash string } // CommitStartedPatch records a successful runtime start atomically with the @@ -299,6 +373,12 @@ func CommitStartedPatch(input CommitStartedPatchInput) MetadataPatch { if input.StartsAwakeInterval { patch["awake_started_at"] = awakeIntervalStartedAt(input.Now) } + // Priming confirmation pair (both-or-neither). Stamped atomically with + // started_config_hash so priming inherits its crash semantics and lifetime. + if !input.PrimedAt.IsZero() && input.PromptHash != "" { + patch[PrimedAtMetadataKey] = input.PrimedAt.UTC().Format(time.RFC3339) + patch[PromptHashMetadataKey] = input.PromptHash + } return patch } @@ -390,6 +470,11 @@ func RestartRequestPatch(sessionKey string, now time.Time) MetadataPatch { "pending_create_claim": "", "pending_create_started_at": "", } + // A restart handoff clears started_config_hash to force the next wake onto a + // first-start path, so the priming markers share that clear (S19 Stage 2 + // priming-key lifetime rule): the fresh conversation must re-prime rather + // than inherit the previous incarnation's confirmation pair. + clearPrimingMarkers(patch) if sessionKey != "" { patch["session_key"] = sessionKey } @@ -497,6 +582,11 @@ func RetireNamedSessionPatch(now time.Time, reason, identity string) MetadataPat patch["alias"] = "" patch["session_name"] = "" patch["session_name_explicit"] = "" + // Free the durable canonical-identity record (S19) alongside the legacy + // alias/session_name identifiers, so an archived duplicate/removed named + // session no longer carries a live canonical instance name or pool slot — + // matching this patch's contract that canonical identifiers are freed. + freeCanonicalIdentityMetadata(patch) patch["synced_at"] = now.UTC().Format(time.RFC3339) patch["held_until"] = "" patch["quarantined_until"] = "" diff --git a/internal/session/lifecycle_transition_test.go b/internal/session/lifecycle_transition_test.go index 6fd088c177..ef454da7b3 100644 --- a/internal/session/lifecycle_transition_test.go +++ b/internal/session/lifecycle_transition_test.go @@ -94,6 +94,9 @@ func TestLifecycleTransitionPatchesSetCompleteMetadata(t *testing.T) { "started_live_hash": "", "live_hash": "", "startup_dialog_verified": "", + "primed_at": "", + "priming_attempted_at": "", + "prompt_hash": "", }, }, { @@ -116,6 +119,9 @@ func TestLifecycleTransitionPatchesSetCompleteMetadata(t *testing.T) { "started_live_hash": "", "live_hash": "", "startup_dialog_verified": "", + "primed_at": "", + "priming_attempted_at": "", + "prompt_hash": "", "continuation_reset_pending": "true", }, }, @@ -180,6 +186,9 @@ func TestLifecycleTransitionPatchesSetCompleteMetadata(t *testing.T) { "started_live_hash": "", "live_hash": "", "startup_dialog_verified": "", + "primed_at": "", + "priming_attempted_at": "", + "prompt_hash": "", "continuation_reset_pending": "true", }, }, @@ -200,6 +209,9 @@ func TestLifecycleTransitionPatchesSetCompleteMetadata(t *testing.T) { "started_live_hash": "", "live_hash": "", "startup_dialog_verified": "", + "primed_at": "", + "priming_attempted_at": "", + "prompt_hash": "", "continuation_reset_pending": "true", }, }, @@ -215,6 +227,11 @@ func TestLifecycleTransitionPatchesSetCompleteMetadata(t *testing.T) { "pending_create_claim": "", "pending_create_started_at": "", "session_key": "new-session-key", + // Priming markers share started_config_hash's lifetime (S19 + // Stage 2 C-7): a restart handoff forces a fresh re-prime. + "primed_at": "", + "priming_attempted_at": "", + "prompt_hash": "", }, }, { @@ -228,6 +245,11 @@ func TestLifecycleTransitionPatchesSetCompleteMetadata(t *testing.T) { "last_woke_at": "", "pending_create_claim": "", "pending_create_started_at": "", + // Priming markers share started_config_hash's lifetime (S19 + // Stage 2 C-7): a restart handoff forces a fresh re-prime. + "primed_at": "", + "priming_attempted_at": "", + "prompt_hash": "", }, }, { @@ -239,6 +261,9 @@ func TestLifecycleTransitionPatchesSetCompleteMetadata(t *testing.T) { "started_live_hash": "", "live_hash": "", "startup_dialog_verified": "", + "primed_at": "", + "priming_attempted_at": "", + "prompt_hash": "", "last_woke_at": "", "restart_requested": "", "continuation_reset_pending": "true", @@ -256,6 +281,9 @@ func TestLifecycleTransitionPatchesSetCompleteMetadata(t *testing.T) { "started_live_hash": "", "live_hash": "", "startup_dialog_verified": "", + "primed_at": "", + "priming_attempted_at": "", + "prompt_hash": "", "last_woke_at": "", "restart_requested": "", "continuation_reset_pending": "true", @@ -273,6 +301,9 @@ func TestLifecycleTransitionPatchesSetCompleteMetadata(t *testing.T) { "started_live_hash": "", "live_hash": "", "startup_dialog_verified": "", + "primed_at": "", + "priming_attempted_at": "", + "prompt_hash": "", "last_woke_at": "", "restart_requested": "", "continuation_reset_pending": "true", @@ -325,6 +356,8 @@ func TestLifecycleTransitionPatchesSetCompleteMetadata(t *testing.T) { "alias": "", "session_name": "", "session_name_explicit": "", + "canonical_instance_name": "", + "canonical_pool_slot": "", "pending_create_claim": "", "pending_create_started_at": "", "retired_named_identity": "worker", diff --git a/internal/session/manager.go b/internal/session/manager.go index f230fa29f4..954ed650e3 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -143,6 +143,19 @@ type Info struct { ManualSessionMetadata string Labels []string // bead labels (agent: identity fallback + canonical checks) + // CanonicalInstanceNameMetadata / CanonicalPoolSlotMetadata are the RAW + // canonical-identity record mirrors (canonical_instance_name / + // canonical_pool_slot), verbatim. They follow the DependencyOnlyMetadata / + // PendingCreateClaimMetadata house pattern: projected by InfoFromPersistedBead + // and folded per-key (verbatim copy) by ApplyPatch, so the two keys round-trip + // through the fold-vs-reproject oracle trivially. The typed record is derived + // on demand by the Info.CanonicalIdentity() accessor over these mirrors, never + // stored, so nothing can go stale after a heal. Additive, internal-only + // (absent from the HTTP wire). S19 Stage 2 is WRITE-ONLY: stamped at + // create/adoption but read by no decision path yet. + CanonicalInstanceNameMetadata string // canonical_instance_name (raw) + CanonicalPoolSlotMetadata string // canonical_pool_slot (raw) + // MCPIdentity / MCPServersSnapshot mirror the raw mcp_identity and // mcp_servers_snapshot metadata (verbatim). The ACP-transport classifier // treats a non-empty value on either key as evidence the session speaks ACP, @@ -1191,6 +1204,12 @@ func (m *Manager) retireConfiguredNamedSessionIdentifiers(id string, b beads.Bea update.Metadata["session_name_explicit"] = "" update.Metadata["pending_create_claim"] = "" update.Metadata["pending_create_started_at"] = "" + // Free the durable canonical-identity record on this close path too, matching + // RetireNamedSessionPatch. Without it a configured named session closed via + // Manager.Close keeps a stale canonical instance name / pool slot — the same + // strand class the S19 retirement fix removed for the duplicate/removed/API + // paths, which this hand-rolled path is not one of. + freeCanonicalIdentityMetadata(update.Metadata) if err := m.store.Update(id, update); err != nil { return fmt.Errorf("retiring configured named session identifiers: %w", err) } diff --git a/internal/session/manager_test.go b/internal/session/manager_test.go index 5643fd8046..89b71c1eb8 100644 --- a/internal/session/manager_test.go +++ b/internal/session/manager_test.go @@ -310,6 +310,50 @@ func TestCreate(t *testing.T) { } } +// TestRetireConfiguredNamedSessionIdentifiersFreesCanonicalIdentity pins that the +// Manager.Close named-session retirement path frees the durable canonical-identity +// record (canonical_instance_name / canonical_pool_slot) alongside the legacy +// identifiers, matching RetireNamedSessionPatch. Regression guard for the second +// retirement path that stranded canonical identity after the S19 stage-2 fix. +func TestRetireConfiguredNamedSessionIdentifiersFreesCanonicalIdentity(t *testing.T) { + store := beads.NewMemStore() + mgr := NewManagerWithOptions(store, runtime.NewFake()) + + b, err := store.Create(beads.Bead{ + Type: BeadType, + Metadata: map[string]string{ + NamedSessionMetadataKey: "true", + NamedSessionIdentityMetadata: "myrig/worker", + "session_name": "test-city--myrig--worker", + "session_name_explicit": "true", + CanonicalInstanceNameMetadata: "myrig/worker", + CanonicalPoolSlotMetadata: "3", + }, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if err := mgr.retireConfiguredNamedSessionIdentifiers(b.ID, b); err != nil { + t.Fatalf("retireConfiguredNamedSessionIdentifiers: %v", err) + } + + got, err := store.Get(b.ID) + if err != nil { + t.Fatalf("store.Get: %v", err) + } + if v := got.Metadata[CanonicalInstanceNameMetadata]; v != "" { + t.Errorf("%s = %q, want cleared", CanonicalInstanceNameMetadata, v) + } + if v := got.Metadata[CanonicalPoolSlotMetadata]; v != "" { + t.Errorf("%s = %q, want cleared", CanonicalPoolSlotMetadata, v) + } + // Legacy identifiers stay cleared too (unchanged behavior). + if v := got.Metadata["session_name"]; v != "" { + t.Errorf("session_name = %q, want cleared", v) + } +} + func TestCreateKillsUntrackedOrphanBeforeStart(t *testing.T) { store := beads.NewMemStore() sp := &orphanScanProvider{ diff --git a/internal/session/priming_lifetime_gate_test.go b/internal/session/priming_lifetime_gate_test.go new file mode 100644 index 0000000000..1e093b95d1 --- /dev/null +++ b/internal/session/priming_lifetime_gate_test.go @@ -0,0 +1,203 @@ +package session + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// startedConfigHashKey and the priming keys as raw metadata strings. The gate +// below works on source text, so it matches both the raw string literals and +// the exported constants that resolve to them. +const startedConfigHashKey = "started_config_hash" + +var primingKeyValues = map[string]bool{ + "primed_at": true, + "priming_attempted_at": true, + "prompt_hash": true, +} + +// primingConstToValue maps the exported priming-key constants to their metadata +// string, so a clear written as sessionpkg.PrimedAtMetadataKey is recognized the +// same as the raw "primed_at". +var primingConstToValue = map[string]string{ + "PrimedAtMetadataKey": "primed_at", + "PrimingAttemptedAtMetadataKey": "priming_attempted_at", + "PromptHashMetadataKey": "prompt_hash", +} + +// TestEveryStartedConfigHashClearAlsoClearsPriming is the repo-wide LIFETIME-RULE +// gate (S19 Stage 2 §C). Every non-test function that clears started_config_hash +// to "" MUST also clear all three priming markers in the same function, so a +// future clear site cannot silently strand a stale confirmation pair on a fresh +// incarnation (spec risk #5). The gate parses the two source trees that own the +// clear sites (internal/session, cmd/gc) and fails if any clearing function skips +// the priming reset. +func TestEveryStartedConfigHashClearAlsoClearsPriming(t *testing.T) { + root := repoRoot(t) + trees := []string{ + filepath.Join(root, "internal", "session"), + filepath.Join(root, "cmd", "gc"), + } + + var clearingFuncs int + for _, dir := range trees { + files, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("reading %s: %v", dir, err) + } + for _, f := range files { + if f.IsDir() || !strings.HasSuffix(f.Name(), ".go") || strings.HasSuffix(f.Name(), "_test.go") { + continue + } + path := filepath.Join(dir, f.Name()) + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + t.Fatalf("parsing %s: %v", path, err) + } + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + cleared := collectEmptyStringClears(fn.Body) + usesHelper := funcUsesPrimingHelper(fn.Body) + if !cleared[startedConfigHashKey] { + continue + } + clearingFuncs++ + if usesHelper { + continue + } + missing := []string{} + for v := range primingKeyValues { + if !cleared[v] { + missing = append(missing, v) + } + } + if len(missing) > 0 { + rel, _ := filepath.Rel(root, path) + t.Errorf("%s: %s clears started_config_hash=\"\" but does not clear priming markers %v "+ + "(S19 Stage 2 lifetime rule: every started_config_hash clear must clearPrimingMarkers)", + rel, fn.Name.Name, missing) + } + } + } + } + + // Sanity: the gate must actually be scanning real clear sites, else a parse + // or path regression would make it silently vacuous. + if clearingFuncs < 7 { + t.Fatalf("gate found only %d started_config_hash clear sites, expected the 7 known C-sites; scan is stale", clearingFuncs) + } +} + +// funcUsesPrimingHelper reports whether the function body clears the priming +// markers through the shared helper — a clearPrimingMarkers(...) call or a range +// over primingResetKeys — either of which clears all three keys at once. +func funcUsesPrimingHelper(body *ast.BlockStmt) bool { + found := false + ast.Inspect(body, func(n ast.Node) bool { + switch node := n.(type) { + case *ast.CallExpr: + if id, ok := node.Fun.(*ast.Ident); ok && id.Name == "clearPrimingMarkers" { + found = true + } + case *ast.RangeStmt: + if id, ok := node.X.(*ast.Ident); ok && id.Name == "primingResetKeys" { + found = true + } + } + return true + }) + return found +} + +// collectEmptyStringClears returns the set of metadata keys the function clears +// to "" — via map composite literals, index assignments, or SetMetadata calls. +// Keys named by the priming constants are normalized to their string value. +func collectEmptyStringClears(body *ast.BlockStmt) map[string]bool { + cleared := map[string]bool{} + record := func(keyNode, valNode ast.Expr) { + if !isEmptyStringLit(valNode) { + return + } + if k, ok := metadataKeyName(keyNode); ok { + cleared[k] = true + } + } + ast.Inspect(body, func(n ast.Node) bool { + switch node := n.(type) { + case *ast.KeyValueExpr: + record(node.Key, node.Value) + case *ast.AssignStmt: + for i, lhs := range node.Lhs { + idx, ok := lhs.(*ast.IndexExpr) + if !ok || i >= len(node.Rhs) { + continue + } + record(idx.Index, node.Rhs[i]) + } + case *ast.CallExpr: + // SetMetadata(id, key, value) — the trailing two args are (key, value). + if sel, ok := node.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "SetMetadata" && len(node.Args) >= 3 { + n := len(node.Args) + record(node.Args[n-2], node.Args[n-1]) + } + } + return true + }) + return cleared +} + +// metadataKeyName resolves a key expression to its metadata string value: +// a raw string literal, or one of the known priming/started-config constants +// (bare or package-qualified). +func metadataKeyName(e ast.Expr) (string, bool) { + switch node := e.(type) { + case *ast.BasicLit: + if node.Kind == token.STRING { + if s, err := strconv.Unquote(node.Value); err == nil { + return s, true + } + } + case *ast.Ident: + if v, ok := primingConstToValue[node.Name]; ok { + return v, true + } + case *ast.SelectorExpr: + if v, ok := primingConstToValue[node.Sel.Name]; ok { + return v, true + } + } + return "", false +} + +func isEmptyStringLit(e ast.Expr) bool { + lit, ok := e.(*ast.BasicLit) + return ok && lit.Kind == token.STRING && (lit.Value == `""` || lit.Value == "``") +} + +func repoRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("could not locate repo root (no go.mod found walking up)") + } + dir = parent + } +} diff --git a/internal/session/priming_markers_test.go b/internal/session/priming_markers_test.go new file mode 100644 index 0000000000..baa18f4651 --- /dev/null +++ b/internal/session/priming_markers_test.go @@ -0,0 +1,138 @@ +package session + +import ( + "testing" + "time" +) + +// TestPromptHash pins the empty-prompt gate, determinism, and distinctness. +func TestPromptHash(t *testing.T) { + if got := PromptHash(""); got != "" { + t.Fatalf("PromptHash(\"\") = %q, want empty (the P5 empty-prompt gate)", got) + } + a1 := PromptHash("hello world") + a2 := PromptHash("hello world") + if a1 == "" { + t.Fatal("PromptHash of a non-empty prompt must be non-empty") + } + if a1 != a2 { + t.Fatalf("PromptHash not deterministic: %q vs %q", a1, a2) + } + if b := PromptHash("hello world!"); b == a1 { + t.Fatalf("distinct prompts hashed to the same value %q", a1) + } +} + +// TestCommitStartedPatchPriming proves the confirmation pair is both-or-neither +// and never stamps priming_attempted_at. +func TestCommitStartedPatchPriming(t *testing.T) { + now := time.Date(2026, 7, 8, 1, 2, 3, 0, time.UTC) + + t.Run("zero PrimedAt stamps no priming keys", func(t *testing.T) { + patch := CommitStartedPatch(CommitStartedPatchInput{CoreHash: "c", PromptHash: "h", Now: now}) + assertNoPrimingKeys(t, patch) + if _, ok := patch["started_config_hash"]; !ok { + t.Error("started_config_hash must still be written") + } + }) + + t.Run("PrimedAt with empty hash stamps nothing (P5)", func(t *testing.T) { + patch := CommitStartedPatch(CommitStartedPatchInput{CoreHash: "c", PrimedAt: now, PromptHash: "", Now: now}) + assertNoPrimingKeys(t, patch) + }) + + t.Run("both set stamps both, RFC3339 + verbatim", func(t *testing.T) { + patch := CommitStartedPatch(CommitStartedPatchInput{CoreHash: "c", PrimedAt: now, PromptHash: "abc123", Now: now}) + if got, want := patch[PrimedAtMetadataKey], now.UTC().Format(time.RFC3339); got != want { + t.Errorf("primed_at = %q, want %q", got, want) + } + if got := patch[PromptHashMetadataKey]; got != "abc123" { + t.Errorf("prompt_hash = %q, want %q", got, "abc123") + } + if _, ok := patch[PrimingAttemptedAtMetadataKey]; ok { + t.Error("CommitStartedPatch must never emit priming_attempted_at") + } + }) + + t.Run("started_config_hash writer set unchanged", func(t *testing.T) { + // The priming pair must not perturb started_config_hash's value. + patch := CommitStartedPatch(CommitStartedPatchInput{CoreHash: "core-x", PrimedAt: now, PromptHash: "h", Now: now}) + if got := patch["started_config_hash"]; got != "core-x" { + t.Errorf("started_config_hash = %q, want core-x", got) + } + }) +} + +// TestPrimingKeysClearedWhereverStartedConfigHashClears is the greppable form of +// the priming-key lifetime rule for the internal/session-owned clear sites +// (C-1..C-3): every one clears the three priming keys exactly when it clears +// started_config_hash. +func TestPrimingKeysClearedWhereverStartedConfigHashClears(t *testing.T) { + t.Run("C-1 applyFreshWakeConversationReset", func(t *testing.T) { + patch := MetadataPatch{} + applyFreshWakeConversationReset(patch) + assertClearsStartedHashAndPriming(t, patch) + }) + + t.Run("C-2 ConversationResetPatch clears when hash clears", func(t *testing.T) { + cleared := ConversationResetPatch(true) + assertClearsStartedHashAndPriming(t, cleared) + + // Churn arm keeps the hash — and therefore the markers. + kept := ConversationResetPatch(false) + if _, ok := kept["started_config_hash"]; ok { + t.Fatal("churn arm must not clear started_config_hash") + } + for _, k := range primingResetKeys { + if _, ok := kept[k]; ok { + t.Errorf("churn arm must not clear priming key %s", k) + } + } + }) + + t.Run("C-7 RestartRequestPatch", func(t *testing.T) { + patch := RestartRequestPatch("sess-key", time.Now()) + assertClearsStartedHashAndPriming(t, patch) + }) +} + +// TestFreshWakeResetKeysAlignWithApply enforces the C-1 alignment note: the +// three priming keys appear in BOTH freshWakeConversationResetKeys and the +// applyFreshWakeConversationReset output. +func TestFreshWakeResetKeysAlignWithApply(t *testing.T) { + listed := map[string]bool{} + for _, k := range freshWakeConversationResetKeys { + listed[k] = true + } + applied := MetadataPatch{} + applyFreshWakeConversationReset(applied) + for _, k := range primingResetKeys { + if !listed[k] { + t.Errorf("priming key %s missing from freshWakeConversationResetKeys", k) + } + if v, ok := applied[k]; !ok || v != "" { + t.Errorf("priming key %s not cleared by applyFreshWakeConversationReset", k) + } + } +} + +func assertNoPrimingKeys(t *testing.T, patch MetadataPatch) { + t.Helper() + for _, k := range primingResetKeys { + if _, ok := patch[k]; ok { + t.Errorf("unexpected priming key %s in patch", k) + } + } +} + +func assertClearsStartedHashAndPriming(t *testing.T, patch MetadataPatch) { + t.Helper() + if got, ok := patch["started_config_hash"]; !ok || got != "" { + t.Fatalf("started_config_hash not cleared (got %q, ok=%v)", got, ok) + } + for _, k := range primingResetKeys { + if got, ok := patch[k]; !ok || got != "" { + t.Errorf("priming key %s not cleared (got %q, ok=%v)", k, got, ok) + } + } +} From e8f07103766653f2697fa7462c9664b377217305 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Thu, 9 Jul 2026 14:33:25 -0700 Subject: [PATCH 036/225] feat(worker): add gpt-5.6-sol/terra/luna codex model choices (#4113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Registers three new supported codex/OpenAI models — `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna` — as `BuiltinOptionChoice` entries in the codex provider's `model` `OptionsSchema` (`internal/worker/builtin/profiles.go`), placed newest-first ahead of `gpt-5.5` per the file's descending-version convention. ## Why They surface in the model dropdown and resolve to `--model ` / `-m ` launch args. Everything downstream derives generically from this single list: - config `ProviderSpec` materialization (`deepCopyProviderOptions`) - dashboard model choices (served from the builtin `OptionsSchema` via the API) - context-window sizing — `gpt-5.6-*` already matches the `gpt-5` substring in `internal/sessionlog/context.go` → 258k, no change needed So no other wiring is required. This is an **add** only — the codex default model (`gpt-5.5`) and `TitleModel` (`o4-mini`) are intentionally unchanged. There is no gascity-side model allowlist and no shipped codex pricing table, so those paths need no edits either. ## Also Refreshes the stale codex model comment in `docs/guides/harness-recipes.md`, which listed the never-shipped `gpt-5.3-codex-spark` alias instead of the real `gpt-5.3-codex`. ## Testing - New guard test `TestBuiltinCodexModelChoicesIncludeGPT56Variants` (value, label, `FlagArgs`, `FlagAliases` for all three) — TDD: written failing first, then made to pass. - `go build ./...`, `go vet ./internal/worker/builtin/` clean. - `internal/config`, `internal/worker/...`, `internal/sessionlog` packages pass. - Adversarial self-review (three independent lenses: correctness/pattern-fidelity, missed-registration-sites, test+doc accuracy) returned zero findings. Note: the local pre-push fast suite flagged one unrelated pre-existing flake, `TestQueueDrainAckAsyncStopTokenFenceSkipsReusedName` (session-reconciler async token-fence race in `cmd/gc`, a package this PR does not touch); it passes 10/10 in isolation. CI runs the authoritative gates. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- docs/guides/harness-recipes.md | 2 +- internal/worker/builtin/profiles.go | 3 ++ internal/worker/builtin/profiles_test.go | 46 ++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/docs/guides/harness-recipes.md b/docs/guides/harness-recipes.md index 7f8e57b7e7..f73ad0e875 100644 --- a/docs/guides/harness-recipes.md +++ b/docs/guides/harness-recipes.md @@ -89,7 +89,7 @@ Reads `OPENAI_BASE_URL` and `OPENAI_API_KEY`. ```toml # Direct provider = "codex" -option_defaults = { model = "gpt-5.5" } # gpt-5.5 · gpt-5.3-codex-spark · o3 · o4-mini +option_defaults = { model = "gpt-5.5" } # gpt-5.6-sol · gpt-5.6-terra · gpt-5.6-luna · gpt-5.5 · gpt-5.3-codex · o3 · o4-mini ``` ```toml diff --git a/internal/worker/builtin/profiles.go b/internal/worker/builtin/profiles.go index 73ffe0be45..2f1082d4fd 100644 --- a/internal/worker/builtin/profiles.go +++ b/internal/worker/builtin/profiles.go @@ -219,6 +219,9 @@ var builtinProviderSpecs = map[string]BuiltinProviderSpec{ Type: "select", Choices: []BuiltinOptionChoice{ {Value: "", Label: "Default"}, + {Value: "gpt-5.6-sol", Label: "GPT-5.6 Sol", FlagArgs: []string{"--model", "gpt-5.6-sol"}, FlagAliases: [][]string{{"-m", "gpt-5.6-sol"}}}, + {Value: "gpt-5.6-terra", Label: "GPT-5.6 Terra", FlagArgs: []string{"--model", "gpt-5.6-terra"}, FlagAliases: [][]string{{"-m", "gpt-5.6-terra"}}}, + {Value: "gpt-5.6-luna", Label: "GPT-5.6 Luna", FlagArgs: []string{"--model", "gpt-5.6-luna"}, FlagAliases: [][]string{{"-m", "gpt-5.6-luna"}}}, {Value: "gpt-5.5", Label: "GPT-5.5", FlagArgs: []string{"--model", "gpt-5.5"}, FlagAliases: [][]string{{"-m", "gpt-5.5"}}}, {Value: "gpt-5.3-codex", Label: "GPT-5.3 Codex", FlagArgs: []string{"--model", "gpt-5.3-codex"}, FlagAliases: [][]string{{"-m", "gpt-5.3-codex"}}}, {Value: "o3", Label: "o3", FlagArgs: []string{"--model", "o3"}, FlagAliases: [][]string{{"-m", "o3"}}}, diff --git a/internal/worker/builtin/profiles_test.go b/internal/worker/builtin/profiles_test.go index bf6f6511a3..1472497883 100644 --- a/internal/worker/builtin/profiles_test.go +++ b/internal/worker/builtin/profiles_test.go @@ -129,3 +129,49 @@ func TestBuiltinCodexModelChoicesUseAvailable53CodexAlias(t *testing.T) { t.Fatal("codex model choices missing gpt-5.3-codex") } } + +func TestBuiltinCodexModelChoicesIncludeGPT56Variants(t *testing.T) { + codex, ok := BuiltinProviders()["codex"] + if !ok { + t.Fatal("BuiltinProviders() missing codex") + } + + var modelOption BuiltinProviderOption + for _, option := range codex.OptionsSchema { + if option.Key == "model" { + modelOption = option + break + } + } + if modelOption.Key == "" { + t.Fatal("codex provider missing model option") + } + + byValue := make(map[string]BuiltinOptionChoice, len(modelOption.Choices)) + for _, choice := range modelOption.Choices { + byValue[choice.Value] = choice + } + + wantLabels := map[string]string{ + "gpt-5.6-sol": "GPT-5.6 Sol", + "gpt-5.6-terra": "GPT-5.6 Terra", + "gpt-5.6-luna": "GPT-5.6 Luna", + } + for value, wantLabel := range wantLabels { + choice, ok := byValue[value] + if !ok { + t.Fatalf("codex model choices missing %q", value) + } + if choice.Label != wantLabel { + t.Errorf("%s label = %q, want %q", value, choice.Label, wantLabel) + } + wantFlagArgs := []string{"--model", value} + if len(choice.FlagArgs) != 2 || choice.FlagArgs[0] != wantFlagArgs[0] || choice.FlagArgs[1] != wantFlagArgs[1] { + t.Errorf("%s FlagArgs = %v, want %v", value, choice.FlagArgs, wantFlagArgs) + } + if len(choice.FlagAliases) != 1 || len(choice.FlagAliases[0]) != 2 || + choice.FlagAliases[0][0] != "-m" || choice.FlagAliases[0][1] != value { + t.Errorf("%s FlagAliases = %v, want [[-m %s]]", value, choice.FlagAliases, value) + } + } +} From 62e4c682882bf6e9a84574c51c068e3888b38bc4 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Thu, 9 Jul 2026 22:19:52 +0000 Subject: [PATCH 037/225] feat(rollout): internal/rollout feature-flag foundation + [beads] conditional_writes gate (PR-1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce internal/rollout, a general, standardized rollout-gate (feature-flag) subsystem for gascity, and register its first two consumers. PR-1b is INERT: the flag resolves everywhere but nothing production consumes it yet — zero behavior change (default Off/legacy). Why: gascity's feature gates today are scattered ad-hoc os.Getenv reads (GC_DOLT_AUTO_GC_ENABLED, GC_EVENTS_ROTATION_ENABLED, ...) with no typing, lifecycle, or test seam. This replaces that pattern with one obvious, testable, maintainable mechanism, and gates gascity's upcoming adoption of the beads compare-and-swap APIs as consumer #1. The subsystem: - A typed Spec registry (Key, Category [infra-rollout|infra-migration| infra-killswitch], ConfigPath, EnvOverride, Default, Owner, Expires, VersionAnchor, SelectsBetween, Justification), validated by ValidateSpecs (reflection-verified config paths, per-category lifecycle anchors, env hygiene) — returns errors, never panics. - A tri-state Mode (Off | Auto | Require) and a GENERAL capability resolver ResolveCapability(ctx, mode, pred) -> {UseLegacy, UseNew, DegradeLoud, RefuseClosed}. Auto uses the new path where a supplied Capability predicate reports capable and loud-degrades otherwise (never a silent unconditional fallback); Require fails closed. Capability-resolution is GENERAL and NOT beads-locked: internal/rollout imports only stdlib + internal/config (enforced by TestRolloutImportBoundary), and capability_test.go drives every cell with a synthetic non-beads predicate. - Resolution with typed Origin, a warn-and-use-config env break-glass (a malformed override is ignored with a typed Notice, never refuse-to-start; an out-of-enum CONFIG value does error — a typo can't mean off), and a per-instance ForTest DI seam with zero process-scoped state (parallel/-race safe). The resolver sources the env name and semantics from the Spec, so the CODEOWNERS-reviewed registry is the source of truth the resolver obeys (a registry<->resolver binding test proves it). - Two day-one Specs: beads.conditional_writes (infra-rollout; VersionAnchor BD_CONDITIONAL_WRITES_MIN_VERSION, pending until beads#4682 is tagged) and daemon.formula_v2 (infra-migration) — the N=2 that makes the abstraction real. Config: BeadsConfig.ConditionalWrites (a plain validated string; the string-> Mode mapping lives in rollout to avoid an import cycle), a compose.go fragment-merge preservation branch so a [beads] fragment can't silently downgrade an explicit conditional_writes, GC_BEADS_CONDITIONAL_WRITES added to testenv.LeakVectorVars, a CODEOWNERS gate on registry.go, and regenerated city-schema. Process: designed by a multi-agent workflow (Opus explore, Fable design/ synthesize/harden), then a Fable red-team review (6 lenses, findings mutation-verified via `go test -overlay`) before commit — its confirmed findings (registry<->resolver drift, vacuous validator rows, the import-cycle fix, env-agrees notice, zero-value doc) are all folded in. Design + full execution plan in engdocs/plans/feature-flags/. golangci-lint clean; internal/rollout green under -race; existing config/ formula_v2 merge tests unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/CODEOWNERS | 3 + docs/reference/config.md | 1 + docs/reference/schema/city-schema.json | 9 + docs/reference/schema/city-schema.txt | 9 + engdocs/plans/feature-flags/DESIGN.md | 2549 +++++++++++++++++ engdocs/plans/feature-flags/EXECUTION-PLAN.md | 915 ++++++ engdocs/plans/feature-flags/STAGE1-CODEMAP.md | 493 ++++ internal/config/compose.go | 9 + internal/config/compose_beads_test.go | 126 + internal/config/config.go | 21 + internal/rollout/boundary_test.go | 64 + internal/rollout/capability.go | 67 + internal/rollout/capability_test.go | 72 + internal/rollout/doc.go | 19 + .../rollout/flag_beads_conditional_writes.go | 33 + internal/rollout/flag_daemon_formula_v2.go | 28 + internal/rollout/flags.go | 47 + internal/rollout/flags_test.go | 38 + internal/rollout/fortest.go | 33 + internal/rollout/fortest_test.go | 40 + internal/rollout/mode.go | 49 + internal/rollout/notice.go | 45 + internal/rollout/registry.go | 79 + internal/rollout/registry_binding_test.go | 190 ++ internal/rollout/registry_test.go | 182 ++ internal/rollout/resolve.go | 108 + internal/rollout/resolve_test.go | 157 + internal/rollout/spec.go | 240 ++ internal/rollout/testenv_import_test.go | 5 + internal/testenv/testenv.go | 5 +- 30 files changed, 5635 insertions(+), 1 deletion(-) create mode 100644 engdocs/plans/feature-flags/DESIGN.md create mode 100644 engdocs/plans/feature-flags/EXECUTION-PLAN.md create mode 100644 engdocs/plans/feature-flags/STAGE1-CODEMAP.md create mode 100644 internal/config/compose_beads_test.go create mode 100644 internal/rollout/boundary_test.go create mode 100644 internal/rollout/capability.go create mode 100644 internal/rollout/capability_test.go create mode 100644 internal/rollout/doc.go create mode 100644 internal/rollout/flag_beads_conditional_writes.go create mode 100644 internal/rollout/flag_daemon_formula_v2.go create mode 100644 internal/rollout/flags.go create mode 100644 internal/rollout/flags_test.go create mode 100644 internal/rollout/fortest.go create mode 100644 internal/rollout/fortest_test.go create mode 100644 internal/rollout/mode.go create mode 100644 internal/rollout/notice.go create mode 100644 internal/rollout/registry.go create mode 100644 internal/rollout/registry_binding_test.go create mode 100644 internal/rollout/registry_test.go create mode 100644 internal/rollout/resolve.go create mode 100644 internal/rollout/resolve_test.go create mode 100644 internal/rollout/spec.go create mode 100644 internal/rollout/testenv_import_test.go diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f4bb0cddfe..1857c0ad64 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -16,6 +16,9 @@ /internal/buildimage/ @gastownhall/gascity-admin /internal/api/dashboardspa/web/package.json @gastownhall/gascity-admin /internal/api/dashboardspa/web/package-lock.json @gastownhall/gascity-admin +# The rollout-gate registry is the only human gate on flag Expires extensions +# and Category classification (an agent-authored repo); admin review is required. +/internal/rollout/registry.go @gastownhall/gascity-admin # Specific docs/ content folders are owned by csells (auto-requests review on # matching PRs). Intentionally scoped to authored-content areas, excluding diff --git a/docs/reference/config.md b/docs/reference/config.md index 6b0e112900..146e0591f2 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -281,6 +281,7 @@ BeadsConfig holds bead store settings. | `backend` | string | | | Backend selects the bd storage engine when Provider is "bd". Empty defaults to "dolt"; T3Code uses "doltlite" for local dev stores. | | `event_hooks` | boolean | | `true` | EventHooks controls installation of the bead event-forwarding hooks (.beads/hooks/on_create,on_update,on_close) that shell out to `gc event emit` on every bead write. Defaults to true. Set to false once the controller's native cache-events already observe bead changes (the bd_hooks doctor gate): the lifecycle then removes the event hooks (leaving git hooks untouched) and stops reinstalling them, clearing the per-write churn and the native-store gate. | | `bd_compatibility` | string | | | BDCompatibility selects the bd CLI semantics Gas City may rely on. Empty defaults to "bd-1.0.4", which keeps claimable work history-backed and avoids bd ready/list flags that are unavailable or incomplete in bd 1.0.4. Enum: `bd-1.0.4`, `bd-1.0.5` | +| `conditional_writes` | string | | | ConditionalWrites selects the bead-write discipline (the rollout gate keyed "beads.conditional_writes"; see internal/rollout): "off" (legacy, byte-identical), "auto" (compare-and-swap where the store is capable, loud degrade otherwise), or "require" (CAS or a typed refusal). Empty defaults to "off". The string is validated and mapped to a rollout.Mode in internal/rollout — never here, which would be an import cycle. Enum: `off`, `auto`, `require` | | `policies` | map[string]BeadPolicyConfig | | | Policies defines per-bead-use storage and garbage-collection defaults. Policy names are interpreted by higher-level systems; unknown names are preserved so packs can stage future policy classes without breaking load. | ## ChatSessionsConfig diff --git a/docs/reference/schema/city-schema.json b/docs/reference/schema/city-schema.json index 4a415d8de5..4568bd4632 100644 --- a/docs/reference/schema/city-schema.json +++ b/docs/reference/schema/city-schema.json @@ -1016,6 +1016,15 @@ ], "description": "BDCompatibility selects the bd CLI semantics Gas City may rely on.\nEmpty defaults to \"bd-1.0.4\", which keeps claimable work history-backed\nand avoids bd ready/list flags that are unavailable or incomplete in bd\n1.0.4." }, + "conditional_writes": { + "type": "string", + "enum": [ + "off", + "auto", + "require" + ], + "description": "ConditionalWrites selects the bead-write discipline (the rollout gate keyed\n\"beads.conditional_writes\"; see internal/rollout): \"off\" (legacy,\nbyte-identical), \"auto\" (compare-and-swap where the store is capable, loud\ndegrade otherwise), or \"require\" (CAS or a typed refusal). Empty defaults to\n\"off\". The string is validated and mapped to a rollout.Mode in\ninternal/rollout — never here, which would be an import cycle." + }, "policies": { "additionalProperties": { "$ref": "#/$defs/BeadPolicyConfig" diff --git a/docs/reference/schema/city-schema.txt b/docs/reference/schema/city-schema.txt index 4a415d8de5..4568bd4632 100644 --- a/docs/reference/schema/city-schema.txt +++ b/docs/reference/schema/city-schema.txt @@ -1016,6 +1016,15 @@ ], "description": "BDCompatibility selects the bd CLI semantics Gas City may rely on.\nEmpty defaults to \"bd-1.0.4\", which keeps claimable work history-backed\nand avoids bd ready/list flags that are unavailable or incomplete in bd\n1.0.4." }, + "conditional_writes": { + "type": "string", + "enum": [ + "off", + "auto", + "require" + ], + "description": "ConditionalWrites selects the bead-write discipline (the rollout gate keyed\n\"beads.conditional_writes\"; see internal/rollout): \"off\" (legacy,\nbyte-identical), \"auto\" (compare-and-swap where the store is capable, loud\ndegrade otherwise), or \"require\" (CAS or a typed refusal). Empty defaults to\n\"off\". The string is validated and mapped to a rollout.Mode in\ninternal/rollout — never here, which would be an import cycle." + }, "policies": { "additionalProperties": { "$ref": "#/$defs/BeadPolicyConfig" diff --git a/engdocs/plans/feature-flags/DESIGN.md b/engdocs/plans/feature-flags/DESIGN.md new file mode 100644 index 0000000000..b218e3584b --- /dev/null +++ b/engdocs/plans/feature-flags/DESIGN.md @@ -0,0 +1,2549 @@ +# Feature-Flag Subsystem — Design (`internal/rollout`) + +_Status: DESIGN for review — no code yet. Produced by a multi-agent workflow (Opus explore · Fable design/synthesize/red-team/harden), 31 agents. First consumer: gating gascity's adoption of the beads compare-and-swap APIs._ + +**Winning approach:** DESIGN 2: Capability-Resolved Rollout Gates (internal/rollout) + +## Why this approach + +Design 2 wins on the two axes that matter most for THIS flag: robustness and CAS fit. Its Off/Auto/Require Mode is the only model that matches how gascity actually rolls out correctness changes (the GC_WORK_RECORD_ENFORCE warn-then-enforce and GC_WISP_GC_* dry-run-then-act precedents) and the only one that lets a mixed fleet adopt CAS without choosing between 'off' and 'refused writes' — while still making Require a hard fail-closed contract and making silent unconditional fallback structurally impossible. Its domain-local config placement ([beads] conditional_writes beside bd_compatibility) inherits the existing fragment-merge machinery with zero new layering code and honors progressive-activation-by-section, where D1's and D3's central [features] tables fight the native idiom and need new merge wiring. Its ResolveConditionalWriter seam puts the enable-AND-capable product in exactly one tested function, and its per-store capability model (interface assert + memoized probe + authoritative exit-13 latch) is the only correct answer for the multi-store reality (graphBeadStore vs drainMemberOwningStore vs the deployed sqlite graph store). All three designs tie at 5 on principle_fit and testability — they share the same defensible line (the exclusion bans agent-behavior toggles that smarter models obviate; infra rollout gates select mechanical transport paths invisible to prompts) and the same DI-value test seam. D2's genuine weakness is lifecycle softness, which is exactly where the runners-up are strongest, so the synthesis grafts D3's teeth (mandatory Expires with past-due CI failure, soft cap on active non-Stable flags, tombstones with their own expiry, the prompt-package import-boundary test, the bd-help-text interim probe, test-failure-not-panic registration) and D1's precision (typed Origin tracking end-to-end, per-FIELD merge discipline with a registry-driven coverage test, EXECUTED machine-checkable removal predicates wired into the TestBDVersionPins lockstep, per-flag Latch metadata, RetiredKeys tombstones in undecoded.go). The result is concrete and buildable in staged PRs: (1) internal/rollout + registry + BeadsConfig field + Resolve at both composition roots; (2) beads.ConditionalWriter + typed errors + BdStore exit-9/13 classifier + dedicated retry policy + Mem/File/Caching/sqlite implementations; (3) C4+C6 CAS call sites; (4) library bump + C2 wire change; (5) formula_v2 migration deleting the global-setter anti-pattern. + +## Design scores (1–5) + +| Design | principle | testability | robustness | maintainability | cas_fit | +|---|---|---|---|---|---| +| DESIGN 1: Config-Native Feature Gates ([features] section + registry in internal/config) | 5 | 5 | 4 | 4 | 4 | +| DESIGN 2: Capability-Resolved Rollout Gates (internal/rollout, Off/Auto/Require Mode on owning config section) | 5 | 5 | 5 | 4 | 5 | +| DESIGN 3: Rollout Registry (internal/rollout, descriptor-first with Expires/soft-cap/tombstone-expiry) | 5 | 5 | 4 | 5 | 4 | + +
Per-design scoring notes + +- **DESIGN 1: Config-Native Feature Gates ([features] section + registry in internal/config)** — Best origin-tracking story (GateValue{Enabled,Origin}), best tombstone/RetiredFeatureKeys handling, and the per-FIELD fragment-merge insight (whole-section replacement resets sibling flags — the exact daemon.formula_v2 footgun) is load-bearing and must survive into any winner. Executable RemovalConditions (version predicates run by a lifecycle test) are the sharpest anti-rot teeth of the three. Weaknesses: bool-only enable gives operators no observe/degrade middle state — on a mixed fleet (one stale bd) the choice is 'off' or 'brick that store's writes', which will stall real rollouts; the registry living inside internal/config bloats an already 4000+-line package; a central [features] table drifts from the progressive-activation-by-owning-section idiom that BDCompatibility already established for exactly this kind of bd-semantics opt-in. +- **DESIGN 2: Capability-Resolved Rollout Gates (internal/rollout, Off/Auto/Require Mode on owning config section)** — The tri-state Mode (Off|Auto|Require) is the single best idea in the set: it matches the in-tree warn-then-enforce precedents (GC_WORK_RECORD_ENFORCE, GC_WISP_GC_* dry-run), gives mixed fleets a loud-degrade path that never silently converts a refused CAS into an unconditional write, and makes graduation a default walk (Off→Auto→Require) instead of a cliff. Domain-local placement ([beads] conditional_writes beside bd_compatibility) inherits the existing IsDefined("beads") fragment merge with ZERO new merge code and honors section-presence activation. ResolveConditionalWriter(store, mode) puts the enable∧capable product in exactly one tested function. Per-store capability (interface assert + memoized probe + exit-13 latch) is correct for the multi-store reality (graphBeadStore vs drainMemberOwningStore vs sqlite). Weaknesses: lifecycle enforcement is softer than Design 3 (GraduationCriterion/RemovalTrigger are fields, not executed predicates; no Expires date, no cap); the Enable-as-closure registry field is awkward; discoverability of domain-scattered fields depends entirely on the registry+doctor. +- **DESIGN 3: Rollout Registry (internal/rollout, descriptor-first with Expires/soft-cap/tombstone-expiry)** — The strongest lifecycle machinery of the three: mandatory Expires on every non-Stable flag with past-due failing CI, a soft cap (~8 active non-Stable flags) that forces cleanup before addition, tombstones that carry their OWN expiry, and the TestBDVersionPins lockstep wiring that makes CI itself demand the default flip. The prompt-package import-boundary test and the 'no scope field can express per-agent' type-system argument are the crispest structural enforcement of the capability-flag line. The `bd update --help` grep for --if-revision is the only workable capability detector for today's untagged beads#4682. Weaknesses: Bool-only for CAS surrenders the Auto degrade mode (its 'CAS has no dry-run' argument conflates observe-the-write with tolerate-the-incapable-store — the latter is what mixed fleets need); mustRegister panics collide with the no-panics-in-library convention; descriptor-keyed Get is marginally weaker than a typed method per flag for compiler-enforced removal; help-text probe fragility is real (mitigated by the exit-13 latch but converts misdetection into runtime refusals). + +
+ +## 1. Overview, goals, and non-goals + +### 1.1 What we are building + +Two deliverables, one design — the second is how the first ships safely: + +1. **Beads CAS adoption.** beads PR gastownhall/beads#4682 gives every bead an opaque `revision int64` nonce and conditional writes: `--if-revision N` on `bd update/close/assign/delete` (exit 9 = precondition failed with a machine JSON body `{code, expected_revision, current_revision}`; exit 13 = refusal, never a silent unconditional fallback) and a library `ConditionalWriter` surface. gascity adopts it for its three known lost-update consumers — the C4 dispatch epoch fence, the C6 drain reservation, and C2 API optimistic concurrency — behind one operator knob: + + ```toml + # city.toml + [beads] + conditional_writes = "auto" # "off" (default) | "auto" | "require" + ``` + +2. **Rollout Gates (`internal/rollout`).** The knob is the first registered consumer of a standardized subsystem for SDK infrastructure rollout/migration gates — a typed field on the owning config section, a mandatory descriptor in one registry file with owner/expiry/removal teeth, one resolution point, and DI-threaded immutable values. It replaces the pattern we would otherwise repeat: a ninth ad-hoc `os.Getenv` gate. + +The core shape, in signatures (full semantics in later sections): + +```go +// internal/rollout — side-effect-free; imports stdlib + internal/config only. +type Mode string +const ( + Off Mode = "off" // legacy path, byte-identical to today + Auto Mode = "auto" // CAS where capable; loud degrade where not + Require Mode = "require" // CAS or typed refusal — fail closed +) + +func Resolve(cfg *config.City, opts ResolveOptions) (Flags, []Notice) // once, in the shared config loaders +func (f Flags) BeadsConditionalWrites() Mode // typed accessor; no string keys anywhere +``` + +```go +// internal/beads — capability is a separate, per-store axis. +type ConditionalWriter interface { + UpdateIssueIfMatch(id string, rev int64, patch IssuePatch) error + CloseIssueIfMatch(id string, rev int64) error + DeleteIssueIfMatch(id string, rev int64) error + CompareAndSetMetadataKey(id, key, expected, next string) (bool, error) +} +``` + +The governing invariant: **effective behavior = operator intent AND runtime capability.** Capability can veto intent (`auto` degrades loudly, `require` refuses with a typed error); it can never raise it, and no code path anywhere converts `ErrConditionalWriteUnsupported` into an unconditional write. + +### 1.2 Why now: the untagged-#4682 reality + +The CAS APIs exist on beads `main` but not in a tagged release. Waiting for the tag — and then for the bundled-`bd` version pin in `deps.env` to cross the floor — would leave three known TOCTOU races open for an unbounded interval: + +- **C4:** `molecule.Attach`'s read-compare-`SetMetadata` on `gc.control_epoch` (molecule.go:262–310) lets two processors both win the epoch fence. +- **C6:** `reserveDrainMember`'s read-then-write on `gc.drain.reserved_by` (drain.go:1222–1246) lets two drains claim one member. +- **C2:** API bead mutations have no optimistic-concurrency story at all. + +Operators who run "beads latest" (the maintainer fleet does) can close these races **today** if the code path exists and is gated. The flag decouples when the code lands from what any given fleet's `bd` supports. Two consequences shape the whole design: + +- **A new knob, orthogonal to `bd_compatibility`.** Opting into CAS on an untagged `bd` must not buy any other future bd-1.1.x semantics. The knobs re-converge at graduation, when the version pin absorbs the floor. +- **Capability is per resolved store, not per process.** One city writes through the bundled `bd` CLI (versioned by `deps.env`), the native Dolt library (versioned by `go.mod`), *and* — on the deployed controller topology that actually holds `gc.control_epoch` and `gc.drain.reserved_by` — a **sqlite graph store**. These upgrade independently; a sqlite `CompareAndSetMetadataKey` is therefore an in-scope blocking deliverable, not a footnote, and capability is probed live per store (never persisted — restart re-probes, per "no status files"). + +### 1.3 Why a subsystem and not a ninth env var + +The tree already contains the counterfactual: `GC_DOLT_AUTO_GC_ENABLED` (env fills only when config is nil), `GC_EVENTS_ROTATION_ENABLED`, `GC_ALLOW_PROD_DOLT_PORT_IN_TESTS`, and the formula_v2 apparatus — a config field wired through `applyFeatureFlags` at 8 scattered `cmd/gc` call sites, a duplicate `syncFeatureFlags` root in the API server, two package-level `atomic.Bool`s, a process-wide test mutex, and ~20 save/restore blocks in `molecule_test.go`. Roughly eight divergent truthy parsers and two contradictory precedence rules. Every new gate copies one of these at random. + +The repo's own rule — no abstraction until two implementations exist — is honored by contract, not deferral: the registry ships in stage 1 with **two** Specs registered on day one (beads CAS as `infra-rollout`, formula_v2 as `infra-migration`, each with owner, version anchor, and expiry), plus freeze tests that make the legacy mechanism un-copyable (golden-list boundary test on `SetFormulaV2Enabled`/`applyFeatureFlags`/`syncFeatureFlags` call sites; frozen baseline on new `GC_*` env reads). The formula_v2 code migration is a committed blocking bead in the same milestone; its slippage trips its own registered Spec's lifecycle teeth. + +### 1.4 The principle line, stated once + +AGENTS.md's permanent exclusion — "No capability flags — a sentence in the prompt is sufficient" — bans Go-side toggles over **agent behavior**, the kind a smarter model makes redundant. A rollout gate selects between two **mechanical transports** (conditional vs unconditional write), invisible to every prompt and template; no model improvement changes whether the operator's installed `bd` parses `--if-revision` or whether the sqlite store implements an interface. The litmus, applied per flag: *would a 10x-smarter model obviate this?* Yes → forbidden, put the sentence in the prompt. No → infra gate, belongs in config. CI blocks the naive smuggling paths (closed `Category` enum with no agent-capability member, no scope field on Spec, the prompt-package import boundary and AST lint); the semantic classification is enforced by a named-human CODEOWNERS gate on the registry file. The principle-fit section carries the full argument and its honest limits; nothing else in this document relitigates it. + +### 1.5 Goals — acceptance criteria + +"Testable / robust / maintainable" are pass/fail gates on the PRs, not aspirations. + +**Testable** — merged only if: + +- Zero package-level mutable flag state: no `atomic.Bool`, no `SetX()`, no singleton. `Flags` is an immutable value threaded by DI; the mode has exactly one home (the beads factory stamps it onto every store it opens). +- Tests build flag state per instance via typed options — `rollout.ForTest(t, rollout.WithBeadsConditionalWrites(rollout.Require))` — so deleting a flag breaks tests at **compile time**. No string-keyed override path exists. +- `Resolve` takes an injected `LookupEnv`; no `t.Setenv` anywhere; `GC_BEADS_CONDITIONAL_WRITES` is registered in testenv `LeakVectorVars` (registry-test enforced). Everything is `t.Parallel`-safe by construction, not discipline. +- Capability-absent is an instance toggle on fake stores (interface set intact), and a store-agnostic `ConditionalWriter` conformance suite passes over MemStore, FileStore, CachingStore-over-MemStore, and sqlite in unit CI, plus BdStore against real `bd` under `//go:build integration`. + +**Robust** — merged only if: + +- `off` is byte-identical to today, asserted by test. Nobody who does nothing is affected. +- The four-cell matrix holds and each cell is tested per consumer: + + | mode | store capable | behavior | + |---|---|---| + | `off` | — | legacy write, byte-identical | + | `auto` | yes | CAS | + | `auto` | no | legacy write + once-latched diagnostic + typed degrade event | + | `require` | no | typed refusal + store-open preflight + doctor ERROR (fail closed) | + +- No silent fallback is expressible: config typos are fatal at load (registry-driven enum validation); an unparseable env value on a correctness flag fails startup fast; a fragment defining an unrelated `[beads]` sibling key cannot reset the flag (per-field merge preservation + hand-written regression test). +- Mode is process-latched: it can never flip mid-run; the reload path carries the boot snapshot and surfaces divergence as a "pending restart" notice. Every degrade/refusal diagnostic carries `mode` + `origin` in its first line. + +**Maintainable** — merged only if: + +- Adding a flag is one PR touching four test-enforced places (config field + accessor, Spec, `Flags` accessor, DI threading); removing one is compile-enforced (the accessor's deletion finds every consumer) plus a version-anchored tombstone for the retired TOML key. +- Every lifecycle check in the merge-blocking path is deterministic per commit — no wall-clock-vs-`time.Now()` anywhere in Check. Graduation is a plain Go test against `deps.env` version anchors (Off→Auto when `BD_VERSION` crosses the floor; deletion when `BD_PREV_VERSION` does); calendar staleness lives in a non-blocking nightly radar that files beads. +- Per-category rules are enforced: `infra-rollout`/`infra-migration` flags can never be immortal — their terminal state is deletion; only `infra-killswitch` may be long-lived. `registry.go` is CODEOWNERS-gated with dual Owner (bead + GitHub handle). + +For orientation, resolution precedence (exact semantics are pinned in the resolution section): + +| # | layer | note | +|---|---|---| +| 1 | built-in default (`Spec.Default`) | CAS: `off` | +| 2 | merged config (pack → city → fragment → patch) | existing loader chain, untouched | +| 3 | env override (`GC_BEADS_CONDITIONAL_WRITES`) | break-glass; per-process; strict grammar | +| 4 | per-store capability | veto only — can never raise a mode | +| 5 | test override (`rollout.ForTest`) | structural; tests never call `Resolve` | + +### 1.6 Non-goals — what v1 deliberately excludes + +Each exclusion is a decision with a reopening condition, not an omission. + +- **Per-layer origin provenance.** Origin is collapsed to the three values recoverable with zero loader changes: `builtin | config | env`. That answers the one audit question that matters ("is a forgotten env var pinning this?"). "Which fragment set this" requires new per-field provenance plumbing through `mergeFragment`; deferred until someone asks, costed honestly as compose.go surgery then. +- **`/v0/config/explain` extension.** Rides on per-layer origin; deferred with it. Slice 1 observability is `gc doctor` only; the typed status-wire surface arrives in stage 4, riding the `go.mod`-bump PR that already forces the OpenAPI/dashboard regen for `Bead.Revision`. +- **Hot-reload / reload-tolerant flags.** v1 is **process-latched for all flags**; the `Latch` Spec field does not exist. The reload path carries the boot-resolved snapshot into all later-constructed components — never a re-resolved mode — because a legacy writer racing a CAS writer on `gc.control_epoch` inside one process is the exact corruption the flag prevents. Reload tolerance returns only when a concrete reload-tolerant flag exists. +- **Per-rig scope.** City-global only. No planned gate needs per-rig granularity; scope machinery waits for a concrete consumer. +- **Per-agent scope.** Refused by the registry (no scope field), guarded by a reflection test on `config.Agent`/`AgentPatch`/`AgentOverride`, and documented as the forbidden shape regardless of declaration site. This one is permanent, not v1. +- **A dynamic flag service.** No percentage rollouts, no runtime toggling, no remote config, no persisted flag or capability state. Capability is probed from live state and cached only in-process; restart re-probes. +- **Wholesale absorption of legacy env vars.** `GC_DOLT_AUTO_GC_ENABLED` and `GC_EVENTS_ROTATION_ENABLED` migrate in stage 5 with their existing precedence preserved per-Spec (`EnvSemantics: fills-nil`); any precedence unification is a separate, release-noted breaking change — never a migration side effect. +- **Fleet-wide writer coordination.** CAS mutual exclusion holds only when every writer to a ledger is CAS-active or exactly one writer exists. v1 documents that invariant in the runbook and warns in doctor under declared multi-writer topologies; it does not enforce single-writer fleets. +- **Speculative machinery.** No generic merge-coverage reflection harness (hand-written per-flag merge tests, per the `daemon.formula_v2` template), no removal-predicate DSL (one ~20-line Go test), no flag-count cap (deleted; the anti-rot teeth are expiry anchors and owners, not a ceiling). + +## 2. Principle reconciliation: rollout gates are not capability flags + +AGENTS.md lists "No capability flags — a sentence in the prompt is sufficient" under **What Gas City does NOT contain**. Every member of that list — no skills system, no MCP registration, no decision logic in Go, no hardcoded roles — governs the same thing: what a *reasoning agent* may do. And every member is justified by one criterion, stated in the same section: each excluded thing becomes **less** useful as models improve. A Go-side toggle over agent behavior is banned because a smarter model makes it redundant — the prompt already carries the intent, and the toggle is a heuristic crutch that rots. + +A rollout gate is a different object. It selects between two **mechanical transports** — for beads CAS, between `bd update --if-revision N` and today's unconditional `bd update` — based on a deployment fact: which bd binary is installed, whether a store implements `ConditionalWriter`, whether the operator has scheduled the migration. No prompt, template, or agent can observe the difference; both branches move the same bytes to the same ledger with different concurrency guarantees. + +### 2.1 The litmus + +Two questions, asked of every proposed flag. They are printed in the header of `internal/rollout/registry.go` and repeated in the PR template: + +1. **Would a 10x-smarter model make this flag unnecessary?** If yes, it is an agent-capability flag. Delete it and move the sentence to the prompt. +2. **Do both branches move bytes rather than make decisions?** If either branch encodes a judgment call (`if idle > N then nudge`), it is decision logic in Go wearing infra clothes — also banned, by a different clause of the same list. + +Applied to beads CAS, the answer to (1) is *no* on every leg: no model improvement changes whether the operator's installed bd parses `--if-revision`, whether the sqlite graph store holding `gc.control_epoch` implements `CompareAndSetMetadataKey`, or whether the fleet has finished its migration window. The answer to (2) is *yes*: the gate is `mode != off && store satisfies ConditionalWriter` — interface satisfaction ANDed with a static operator input, the same mechanics as the existing `bdReadyProjectionEnabled` version gate. No heuristic, no threshold, no reasoning. + +This is not a novel carve-out. `daemon.formula_v2` and `beads.bd_compatibility` are in-tree, accepted flags of exactly this kind. The subsystem standardizes settled practice; it does not open a new category. + +### 2.2 The shape of the two things + +| | Capability flag (banned) | Rollout gate (this subsystem) | +|---|---|---| +| Governs | What an agent may do / how it behaves | Which mechanical code path the SDK executes | +| Visible to prompts | Yes — that is its purpose | Never — enforced below | +| Obviated by smarter models | Yes (the prompt carries the intent) | No (bd's argv parser does not get smarter) | +| Correct home | A sentence in the pack's prompt template | A typed field on the owning config section | +| Terminal state | Should never exist | Deletion, forced by lifecycle teeth (§8) | + +```toml +# Rollout gate: selects a transport. Invisible to every prompt. +[beads] +conditional_writes = "auto" # off | auto | require + +# The forbidden shape — never expressible through this subsystem, +# and flagged in review regardless of where it is declared: +[[agent]] +name = "worker" +# allow_force_push = true <- per-agent behavior toggle. If an agent +# needs to know it, it belongs in the prompt. +``` + +### 2.3 What CI enforces structurally + +The naive smuggling paths are blocked by build-failing tests. Each is concrete and shipped in stage 1 (PR-1a/1b): + +**Closed Category enum.** `Spec.Category` is a three-member closed enum — `infra-rollout | infra-migration | infra-killswitch`. There is no agent-capability member and `registry_test.go` rejects any value outside the set. You cannot register a behavioral flag without misclassifying it, and misclassification is what the human gate (§2.4) exists to catch. + +```go +type Category string + +const ( + InfraRollout Category = "infra-rollout" // adopt a new mechanical path, terminal state: deletion + InfraMigration Category = "infra-migration" // retire an old mechanical path, terminal state: deletion + InfraKillswitch Category = "infra-killswitch" // emergency off for a subsystem, may be long-lived +) + +type Spec struct { + Key string + Category Category // closed enum, no agent-capability member + ConfigPath string // reflection-verified against config.City toml tags + EnvOverride string // "" or one GC_* name in testenv LeakVectorVars + EnvSemantics EnvSemantics + Default string + Owner Owner // bead ID + GitHub handle/team + Expires string // mandatory for rollout/migration, forbidden for killswitch + VersionAnchor string + SelectsBetween [2]string // the two mechanical code paths, named (§2.4) + Justification string // the written litmus answer — documentation, not a CI tooth +} +// Note what is absent: there is no Scope field. The registry cannot +// express a per-agent or per-rig flag at all. +``` + +**No scope field, plus the Agent-struct reflection guard.** The registry's refusal of per-agent scope is real but insufficient on its own — `config.Agent` is a routine extension point with a documented field-sync checklist. So a reflection test fails the build if `config.Agent`, `AgentPatch`, or `AgentOverride` ever gains a field typed `rollout.Mode` (or an accessor returning it). The honest statement: the *registry* makes per-agent flags inexpressible; the *config system* could still express one, and that shape is forbidden by review rule regardless of declaration site (§2.4). + +**The import boundary actually exists.** Today prompt rendering (`renderPrompt`, `buildTemplateData`, `PromptContext`) lives in `package main` of `cmd/gc` — the same package as the composition root that calls `rollout.Resolve`, so a naive "prompt packages must not import rollout" test would be vacuous or permanently red. PR-1a extracts rendering into `internal/prompt` (a mechanical move that also fixes the rendering-in-CLI layering smell). Only then is the forbidden edge testable, and a build-failing test asserts it: **`internal/prompt` imports `internal/rollout` → red**. The instant a flag value would flow into a prompt through the type system's front door, the build blocks it. + +**Registry-driven AST lint.** Import analysis cannot see a value smuggled through `cmd/gc`, which legitimately imports both packages — and `PromptContext.Env` is an open `map[string]string` that flows wholesale into template data. So an AST-level lint (same mechanism as `TestNoLeakVectorReadsAtPackageInit`) asserts, repo-wide: + +- no `PromptContext` construction site references any `rollout.Flags` accessor; +- no write to `PromptContext.Env` references any `rollout.Flags` accessor; +- no template `FuncMap` closure references any `rollout.Flags` accessor. + +The lint is registry-driven — it derives the accessor list from the registry, so it grows automatically with every flag and never needs a hand-maintained denylist. + +**Reverse parity, where it is mechanically definable.** Any config field typed `rollout.Mode` anywhere in `config.City` must have a `Spec` (reflection-checked). `*bool` kill-switches are *not* mechanically distinguishable from ordinary optional config; their classification is review-governed, and this document says so rather than claiming a bidirectional test that cannot exist. + +### 2.4 What review governs — stated honestly + +CI blocks the naive paths. It cannot evaluate semantics: a flag value laundered through a bare `bool` into a template data struct three hops away defeats every check above, and a judgment-in-Go gate (`if idleFor > threshold { nudge() }`) can wear a compliant `infra-killswitch` label. The semantic half of the line is enforced by **review with teeth**, and the teeth are specific: + +- **`SelectsBetween` is mandatory.** Every Spec must name its two mechanical code paths — for CAS: `{"conditional bd write (--if-revision)", "unconditional bd write"}`. An author who cannot fill this field with two transports has written a decision, not a gate, and the review conversation starts from that artifact rather than from vibes. +- **The litmus questions live in the registry file header** and in the PR-template checklist, including the value-flow item: *"does any template data struct field trace to a rollout flag?"* +- **`registry.go` is CODEOWNERS-gated** by a named human team. Every new Spec, every `Expires` extension, every category assignment gets a named-human review — the only real gate for semantic classification in a repo where most PRs are agent-authored. +- **The contributor doc states the rule that closes the config-system gap:** a per-agent toggle that changes what an agent may do is the forbidden shape *regardless of where it is declared* — registry, `config.Agent`, `Agent.Env`, or a bare env var. The worked example is the tempting one: staged per-cohort CAS adoption via `Agent.ConditionalWritesOptIn` is rejected even though it feels like rollout, because per-agent scope is precisely the shape that mutates into behavioral toggles and leaks into prompts via `Agent.Env`. + +We deliberately do not overclaim. `Justification` is checked only for presence; no test can grade its truth. Overclaiming structural enforcement is how checks get cargo-culted and then neutered — the design's posture is a small set of hard mechanical walls plus a named-human gate on the one file every flag must touch. + +### 2.5 The remaining principles, in one pass + +- **"Keep judgment out of Go."** The gate is `enabled && interface-satisfied`. Capability never *raises* a mode (off stays off); reality only vetoes intent. No line of the gate reasons about work. +- **"A primitive must become more useful as models improve."** The gate is orthogonal to model quality by construction — its inputs are a TOML field and an interface assertion. It neither gains nor loses value with smarter models, which is exactly the profile of infrastructure rather than a banned heuristic. +- **"Config is the universal activation mechanism."** Not merely reconciled — it *is* the design: the enable axis is a typed field on the owning config section, resolved through the existing pack→city→fragment→patch chain. Env is a thin audited overlay, not a parallel truth (§4). +- **"No status files — query live state."** Capability is probed from live state (bd subprocess, interface satisfaction, exit-13 outcome) and cached only in-process; nothing is persisted, restart re-probes (§6). +- **SDK self-sufficiency and ZERO roles.** No role name appears in any key, default, or resolution input; removing any `[[agent]]` entry cannot change a flag verdict because agents are nowhere in the resolution path. + +## 3. Flag model and the registry + +### 3.1 Two value kinds, both typed — never an open map + +The subsystem admits exactly two flag value kinds. There is no generic `map[string]bool` "features" bag: an open map would defeat the unknown-key typo detection in `internal/config/undecoded.go` (which is reflection-driven over typed structs), the jsonschema doc generation, and the existing field-sync tests. Every flag is a typed field on its owning config section, and every read is a typed accessor. + +**Kind 1: `rollout.Mode`** — a three-state enum for correctness and migration gates that need an observe/degrade middle state between "off" and "hard contract" (in-tree precedents for the shape: `GC_WORK_RECORD_ENFORCE` warn→enforce, `GC_WISP_GC_*` dry-run→act): + +```go +package rollout + +// Mode is the value kind for correctness/migration gates. +type Mode string + +const ( + Off Mode = "off" // legacy path, byte-identical to pre-flag behavior + Auto Mode = "auto" // new path where the resolved store is capable; + // loud once-latched degrade to legacy otherwise + Require Mode = "require" // new path or typed refusal — fail-closed, + // a silent unconditional fallback does not exist +) +``` + +How capability AND-gates a resolved `Mode` per store is the capability section's topic; the point here is that the *value model* itself carries the degrade state, so a mixed fleet is expressible as configuration rather than as an error condition. + +**Kind 2: `*bool`, nil = built-in default** — for simple kill-switches, generalizing the existing `DaemonConfig.FormulaV2` / `EffectiveAutoGCEnabled` idiom: absent means "the default", explicit `false` (or `true`) is an operator decision, and the pointer distinguishes the two. + +A Spec's kind is implied by which arm of its `Default` is set (§3.3) — there is no separate `Kind` field to drift. + +In TOML, the two kinds look like ordinary fields on their owning sections (placement and fragment-merge rules are the config-placement section's topic): + +```toml +[beads] +conditional_writes = "auto" # rollout.Mode: off | auto | require; absent ⇒ built-in default (off) + +[daemon] +formula_v2 = false # *bool kill-switch: absent ⇒ default (true); explicit false = operator off +``` + +Note the import direction: `internal/rollout` imports `internal/config` (its `Resolve` takes `*config.City`), so config structs cannot reference `rollout.Mode`. A Mode flag's config field is a validated string (`toml:"conditional_writes,omitempty" jsonschema:"enum=off,enum=auto,enum=require"`); the string→`Mode` mapping and the typed read surface (`Flags.BeadsConditionalWrites() Mode`) live in `internal/rollout`. This asymmetry is load-bearing for the reverse-parity tests below. + +### 3.2 Scope: city-global only — and what that claim honestly means + +Flags are city-global. The `Spec` type has **no scope field**: the registry cannot describe a per-rig or per-agent flag, so nobody arrives at per-agent capability toggles by following the paved road. Per-rig scope waits until a concrete gate needs it. + +Stated honestly: the *registry* refuses per-agent scope; the *config system* could still express one — `config.Agent` grows fields by a documented checklist, and `Agent.Env` flows into prompt template data. So the claim is not "inexpressible by construction"; it is the registry's refusal plus two mechanical tripwires plus one review rule: + +1. A reflection test in `internal/rollout`'s test package (which may import both packages — no production cycle) fails if `config.Agent`, `config.AgentPatch`, or `config.AgentOverride` ever gains a `rollout.Mode`-typed field. +2. The reverse-parity walk (§3.6) flags any Mode-shaped config field anywhere that lacks a Spec. +3. The contributor doc states the rule the tests cannot check: **a per-agent toggle that changes what an agent may do is the forbidden capability-flag shape regardless of where it is declared.** (The full principle-line enforcement — prompt-boundary import test, AST lint — is the principle section's topic.) + +### 3.3 The Spec: nine load-bearing fields, each with a tooth + +`internal/rollout/registry.go` holds one descriptor per flag. Every surviving field either does mechanical work in a test or gates review; the fields that were pure form-filling were deleted (see the end of this subsection). + +```go +// Category classifies why a gate exists and selects its lifecycle rules. +// The enum is CLOSED: there is no agent-capability member and none may be added. +type Category string + +const ( + InfraRollout Category = "infra-rollout" // staged adoption of a new mechanical transport + InfraMigration Category = "infra-migration" // retiring a legacy in-tree mechanism + InfraKillswitch Category = "infra-killswitch" // operator emergency-off for a shipped subsystem +) + +// EnvSemantics pins how a Spec's env var interacts with explicit config. +type EnvSemantics string + +const ( + EnvOverrides EnvSemantics = "overrides" // env wins over explicit config (break-glass; default for new flags) + EnvFillsNil EnvSemantics = "fills-nil" // env applies only when config leaves the field unset + // (preserves absorbed legacy flags' shipped precedence) +) + +// Default carries the built-in value. Exactly one arm is set; the set arm +// determines the flag's value kind. Enforced by Validate. +type Default struct { + Mode *Mode + Bool *bool +} + +// Owner is dual: the bead tracks the work; the GitHub handle/team is the +// named human the lifecycle radar and CODEOWNERS review actually reach. +type Owner struct { + Bead string // e.g. "ga-9wsri" + GitHub string // "@handle" or "@org/team" +} + +type Spec struct { + Key string // canonical dotted name, e.g. "beads.conditional_writes" + Category Category + ConfigPath string // toml path on config.City; reflection-verified (§3.6) + EnvOverride string // "" or exactly one GC_*-prefixed var + EnvSemantics EnvSemantics // meaningful only when EnvOverride != "" + Default Default + Owner Owner + Expires string // YYYY-MM-DD; feeds the non-blocking nightly radar + VersionAnchor string // repo-pinned removal floor (deps.env key or in-repo version constant) + SelectsBetween [2]string // the two MECHANICAL code paths this flag selects between + Justification string // the written answer to "why doesn't a 10x-smarter model obviate this?" + + // Lifecycle bookkeeping — zero until the corresponding event; validated + // by the lifecycle tests, not by authors at registration time. + GraduatedIn string // version anchor at which the default flipped + FlipDueBy string // bounded machine-checked deferral set by a version-bump PR +} +``` + +Per-field rationale — what each field costs and what enforces it: + +| Field | Job | Tooth | +|---|---|---| +| `Key` | canonical identity; names the flag in doctor, events, notices | non-empty, unique (`Validate`) | +| `Category` | selects enforced lifecycle rules; the closed enum is the structural half of the principle line | member of closed enum; per-category rules in §3.6.2 | +| `ConfigPath` | binds the Spec to its owning config field | reflection-resolved against `config.City` toml tags; type must match kind | +| `EnvOverride` | the one sanctioned break-glass surface | `""` or `GC_*`-prefixed, unique, and registered in `testenv.LeakVectorVars` | +| `EnvSemantics` | prevents absorption of a legacy flag from silently inverting its shipped precedence | member of closed enum; absorbed flags must declare `fills-nil` unless a release-noted breaking change says otherwise | +| `Default` | the built-in value, in ONE home | exactly one arm set; zero-value-config equality test (§3.6.3) closes the two-homes drift | +| `Owner` | who the radar files beads against, who review pings | both parts non-empty; GitHub part matches `@handle`/`@org/team`; the real gate is CODEOWNERS (§3.5) | +| `Expires` | wall-clock staleness signal for the nightly radar and doctor WARN — **never** a merge-blocking date bomb | mandatory for rollout/migration, **forbidden** for killswitch | +| `VersionAnchor` | the deterministic removal floor the two-stage graduation test executes | mandatory (non-empty, syntactically a deps.env key or in-repo anchor) for rollout/migration, forbidden for killswitch; presence *in* deps.env is not required at registration — the lifecycle test arms itself the day the anchor lands (the untagged-#4682 reality) | +| `SelectsBetween` | forces the author to articulate two mechanical transports — the reviewable artifact that separates rollout gates from judgment-in-Go wearing infra clothes | both entries non-empty and distinct; semantic honesty is CODEOWNERS review's job | +| `Justification` | documentation of the principle-line answer, kept where reviewers will read it | **explicitly not a CI tooth** — a non-emptiness check only invites `"n/a"`; the litmus questions live in the registry file header and the human gate is review | + +**Deleted fields, deliberately:** `Stability` (a one-line `Stable` edit was an immortality escape hatch — per-category rules replace it: only killswitches may be long-lived, and that is a property of `Category`, not a mutable tier); `IntroducedIn` (duplicates `git blame`); `GraduationCriterion` (free text duplicating `VersionAnchor`); `Latch` (v1 is process-latched for every flag — a field nothing reads is metadata theater); and the ~8-flag soft cap (governance for a population problem that doesn't exist, whose only failure mode was training people to bump the constant). Each deletion removes a place for form-filling to rot; none removes an enforcement. + +### 3.4 The canonical slice is unexported + +The registry is package-private. No other package — and critically, no *test* — can mutate shared state: + +```go +// specs is the canonical registry. Unexported by design: an exported mutable +// slice would let one test's synthetic append leak into every parallel +// sibling that builds Flags from the registry. +var specs = []Spec{ /* §3.7 */ } + +// Specs returns a defensive copy of the canonical registry. +func Specs() []Spec { + out := make([]Spec, len(specs)) + copy(out, specs) + return out +} + +// Validate reports every structural violation in reg. It takes the registry +// as a PARAMETER: registry_test.go runs it against the canonical set, while +// rollout's own subsystem tests (e.g. "Validate rejects a missing Owner") +// construct throwaway []Spec literals and never touch shared state. +func Validate(reg []Spec) []error +``` + +`Resolve` and `ForTest` likewise consume a `[]Spec` (defaulting to the canonical set), so a validator test provoking a bad Spec and a parallel consumer test building `Flags` are structurally isolated — no cleanup discipline, no ordering dependence. (The typed `With*` override options on `ForTest` are the test-seams section's topic.) + +### 3.5 Dual Owner and the CODEOWNERS gate + +`Owner` is dual on purpose. The bead ID is the work-tracking half — but beads in this project get closed and bulk-purged, and a plain `go test` cannot verify a bead exists, so a bead alone is decorative. The GitHub handle/team is the half that stays reachable, and it is backed by the one mechanism that actually inserts a named human into an agent-authored repo's review loop: + +``` +# .github/CODEOWNERS +/internal/rollout/registry.go @gastownhall/gascity-admin +``` + +Every new Spec, every `Expires` extension, every `FlipDueBy` deferral, and every category claim is a diff to `registry.go` and therefore requires a named-human review. This is stated plainly: the semantic classification of a flag — "is this really an infra gate, or a judgment call wearing infra clothes?" — is **enforced by review-with-teeth, not by CI**. CI blocks the naive paths (closed enum, no scope field, parity walks); the CODEOWNERS gate plus the `SelectsBetween` articulation and the file-header litmus questions ("would a 10x-smarter model obviate this?" / "do both branches move bytes rather than make decisions?") are the enforcement for everything a test cannot judge. + +### 3.6 Registry tests + +`registry_test.go` lives in-package (it can see `specs` and the unexported resolved values without any stringly public API) and fails the build — not panics at init — on violation. Every check is deterministic per commit; no wall-clock comparison appears anywhere in the merge-blocking path. + +1. **Shape and completeness.** Unique non-empty `Key`s; `Category` in the closed enum; exactly one `Default` arm set; `SelectsBetween` entries non-empty and distinct; `Owner.Bead` and `Owner.GitHub` non-empty with the GitHub part matching `@handle`/`@org/team`; `EnvOverride` either `""` or `GC_*`-prefixed and unique across Specs. +2. **Per-category lifecycle rules.** `infra-rollout` and `infra-migration` MUST carry `Expires` and `VersionAnchor` — these categories may never be immortal; their legal terminal state is deletion. `infra-killswitch` MUST carry neither — it is the only legitimately long-lived category, and immortality is a property of the category, not an editable tier. +3. **Default equality (the two-homes drift closer).** `Resolve` over a zero-value `config.City` with an empty injected `LookupEnv` must yield exactly `Spec.Default` for every flag. This is the test that makes a graduation PR atomic: flipping the accessor's `""`→default mapping in `internal/config` without updating `Spec.Default` (or vice versa) is a red build, so `gc doctor` can never render a default the binary doesn't have. +4. **ConfigPath forward parity.** A reflection walk over `config.City`'s toml tags resolves every `Spec.ConfigPath` to a real field, and the field's type must match the Spec's kind: Mode-kind flags land on a `string` field carrying exactly the `enum=off,enum=auto,enum=require` jsonschema tag; bool-kind flags land on a `*bool`. +5. **Reverse parity — the mechanical half only.** The same walk fails on: (a) any field anywhere in `config.City` typed `rollout.Mode` without a Spec (a tripwire — today's import direction makes such a field impossible, and this test keeps it that way); (b) any `string` config field whose jsonschema enum is exactly the Mode spellings but which has no Spec — this signature is how a Mode flag actually manifests in config, so a shadow tri-state gate can't hide in a typed field; (c) any `rollout.Mode`-typed field in `config.Agent`/`AgentPatch`/`AgentOverride`, unconditionally (the per-agent guard from §3.2). What this test cannot do is stated honestly: a `*bool` kill-switch is mechanically indistinguishable from ordinary optional config, so `*bool` classification is review-governed — the frozen `GC_*` env-read baseline and the legacy-mechanism golden list (freeze section) are what make the *bypass* loud, not this walk. +6. **Env hygiene.** Every non-`""` `EnvOverride` must appear in `internal/testenv`'s `LeakVectorVars`, so a live agent-session `GC_BEADS_CONDITIONAL_WRITES=require` can never leak into test processes and flip a test's resolution. + +### 3.7 Day-one contents: born at N=2 + +The registry never exists with one consumer. Stage 1 registers two Specs — the CAS gate whose code lands in stages 2–4, and the existing formula_v2 mechanism whose code migrates in stage 5 but whose *descriptor* (owner, expiry, removal anchor) enters the anti-rot regime immediately, so stage-5 slippage trips the Spec's own lifecycle teeth: + +```go +var specs = []Spec{ + { + Key: "beads.conditional_writes", + Category: InfraRollout, + ConfigPath: "beads.conditional_writes", + EnvOverride: "GC_BEADS_CONDITIONAL_WRITES", // named consumer: hosted cities with + EnvSemantics: EnvOverrides, // baked/immutable config (crucible model) + Default: Default{Mode: ptr(Off)}, + Owner: Owner{Bead: "", GitHub: "@gastownhall/gascity-admin"}, + Expires: "2027-01-15", // radar/doctor WARN signal, never a merge-blocking date + VersionAnchor: "bdConditionalWritesMinVersion", // lands in deps.env when beads tags #4682 + SelectsBetween: [2]string{ + "conditional write: bd --if-revision / store CompareAndSet", + "unconditional read-then-write (legacy, status-quo TOCTOU)", + }, + Justification: "Whether the installed bd parses --if-revision and whether a " + + "resolved store implements ConditionalWriter are deployment facts about " + + "infrastructure versions, invisible to every prompt and template; no model " + + "improvement changes them.", + }, + { + Key: "daemon.formula_v2", + Category: InfraMigration, + ConfigPath: "daemon.formula_v2", + EnvOverride: "", // the legacy mechanism has no env var; none is being added + Default: Default{Bool: ptr(true)}, // matches today's FormulaV2Enabled() nil⇒true + Owner: Owner{Bead: "", GitHub: "@gastownhall/gascity-admin"}, + Expires: "2026-12-31", + VersionAnchor: "gcFormulaV2RemovalFloor", // in-repo gc version anchor for legacy-path deletion + SelectsBetween: [2]string{ + "formula compiler v2 graph workflow infrastructure", + "formula v1 sequential in-session execution", + }, + Justification: "Selects between two shipped execution substrates during a " + + "code migration; which one runs is an operator deployment choice, not " + + "anything a model reasons about.", + }, +} +``` + +Two details worth pinning: the CAS `VersionAnchor` names an anchor that does **not** yet exist in `deps.env` — that is the correct representation of the untagged-#4682 reality, and the two-stage graduation test (lifecycle section) arms itself the day the anchor lands; and formula_v2's registration precedes its code migration by design — the descriptor is the commitment device, the freeze tests (two-consumers section) make the old mechanism un-copyable, and the migration's completion is what deletes `cmd/gc/feature_flags.go` rather than this registry growing a third home for the same flag. + +## 4. Config placement and fragment-merge safety + +### 4.1 The flag lives on the owning section, not in a central table + +The CAS gate is a typed field on `BeadsConfig`, directly beside its closest precedent (`BDCompatibility`, `internal/config/config.go:1377`): + +```go +// internal/config/config.go — BeadsConfig + +// ConditionalWrites selects the write discipline for stores this city opens: +// "off" (legacy read-then-write, byte-identical to today), "auto" (CAS where +// the resolved store is capable, loud degrade otherwise), or "require" +// (CAS or typed refusal — never an unconditional fallback). +// Empty defaults to "off". Rollout gate: see internal/rollout/registry.go +// (Key "beads.conditional_writes") for owner, expiry, and removal trigger. +ConditionalWrites string `toml:"conditional_writes,omitempty" jsonschema:"enum=off,enum=auto,enum=require"` +``` + +```toml +# city.toml — operator opt-in +[beads] +bd_compatibility = "bd-1.0.5" +conditional_writes = "require" +``` + +Read access goes through exactly one pure accessor, which is the *only* place the built-in default is encoded on the config side: + +```go +// ConditionalWritesMode returns the configured conditional-writes mode. +// Load-time validation (§4.3) guarantees any non-empty value is a member of +// the enum; this accessor only ever maps the empty string to the default. +func (b BeadsConfig) ConditionalWritesMode() rollout.Mode { + if b.ConditionalWrites == "" { + return rollout.Off // must equal the registry Spec.Default; registry_test enforces equality + } + return rollout.Mode(b.ConditionalWrites) +} +``` + +Why the owning section and not a central `[features]` table: + +- **Progressive activation is section-presence.** `[beads]` is where an operator already declares beads behavior; a CAS opt-in appearing anywhere else breaks the "config section = capability" model the loader is built around. +- **Layering is inherited, not rebuilt.** pack → city → fragment → patch resolution for `[beads]` already exists; a new table would need its own merge wiring. +- **Discoverability is recovered elsewhere.** Central listing is the registry's job (`internal/rollout/registry.go`, rendered by `gc doctor`), not the TOML file's. + +The registry entry binds the two homes: the CAS Spec's `ConfigPath` is `"beads.conditional_writes"`, and registry_test reflection-resolves it against `City`'s toml tags, so renaming or deleting the field without touching the Spec (or vice versa) fails the build. A second registry assertion constructs a zero-value `config.City` and requires `ConditionalWritesMode() == Spec.Default`, closing the two-homes default drift. + +Unknown *keys* are already handled: `undecoded.go` fatals on a typo'd key name (`conditional_write = "auto"` → unknown-key error with an edit-distance suggestion). This section adds the missing half — bad *values* (§4.3). + +### 4.2 Fragment merge: the mandatory per-field preservation branch + +`mergeFragment` treats `[beads]` as a whole-table last-writer-wins section (`internal/config/compose.go:1030`): + +```go +if fragMeta.IsDefined("beads") { + base.Beads = fragment.Beads +} +``` + +Without intervention this is a silent `require → off` downgrade vector: any included fragment that defines *any* `[beads]` key replaces the whole struct, and the fragment's zero-value `ConditionalWrites` erases the city's explicit opt-in. + +```toml +# city.toml +include = ["shared-pack.toml"] +[beads] +conditional_writes = "require" + +# shared-pack.toml — one unrelated sibling key +[beads] +prefix = "mc" +# → without §4.2, conditional_writes resolves to "" → Off. Doctor shows +# Origin=builtin. The operator believes the epoch fence is enforced. +``` + +The fix is the exact pattern the codebase already carries for its one real rollout flag — the `daemon.formula_v2` preservation branch immediately below (`compose.go:1039-1045`). Every registry flag whose field lives in a whole-table-LWW section MUST get the same hand-written branch: + +```go +// internal/config/compose.go — mergeFragment +if fragMeta.IsDefined("beads") { + conditionalWrites := base.Beads.ConditionalWrites + base.Beads = fragment.Beads + if !fragMeta.IsDefined("beads", "conditional_writes") { + base.Beads.ConditionalWrites = conditionalWrites + } +} +``` + +Semantics, stated precisely: + +- A fragment that **explicitly defines** `beads.conditional_writes` wins (last-writer-wins is preserved for deliberate overrides — a fragment may legitimately set `"auto"` over a pack's `"off"`). +- A fragment that defines **only sibling keys** leaves the base value untouched. `toml.MetaData.IsDefined` distinguishes "key present" from "zero value", which a struct comparison cannot. +- The `daemon` template also preserves across its deprecated `graph_workflows` alias; `conditional_writes` has no alias, so the single-key check is complete. Any future flag that ships with an alias must check both keys, exactly as the daemon branch does. + +**Each such branch gets a hand-written regression test, modeled on `TestLoadWithIncludesPreservesExplicitFormulaV2FalseAcrossDaemonFragment` (`compose_test.go`).** The generic registry-driven reflection merge harness that earlier drafts proposed is deleted: it had zero consumers (no planned flag opens a new section), and `toml.MetaData.IsDefined` has known shape-dependent subtleties that a synthetic-fragment generator would paper over. The proven idiom is a concrete test per flag: + +```go +func TestLoadWithIncludesPreservesConditionalWritesAcrossBeadsFragment(t *testing.T) { + fs := fsys.NewFake() + fs.Files["/city/city.toml"] = []byte(` +include = ["fragment.toml"] + +[workspace] +name = "test" + +[beads] +conditional_writes = "require" +`) + fs.Files["/city/fragment.toml"] = []byte(` +[beads] +prefix = "mc" +`) + cfg, _, err := LoadWithIncludes(fs, "/city/city.toml") + if err != nil { + t.Fatalf("LoadWithIncludes: %v", err) + } + if got := cfg.Beads.ConditionalWritesMode(); got != rollout.Require { + t.Fatalf("ConditionalWritesMode = %q, want require to survive a sibling-key beads fragment", got) + } + if cfg.Beads.Prefix != "mc" { + t.Fatalf("Beads.Prefix = %q, want fragment field applied", cfg.Beads.Prefix) + } +} +``` + +A companion test asserts the deliberate-override direction (fragment sets `conditional_writes = "auto"` → resolved mode is Auto), so the branch can't drift into "base always wins" either. + +Lifecycle rule (recorded in the flag-addition checklist, one sentence, no machinery): *a flag whose config field lands in an existing whole-table-LWW section adds the per-field `IsDefined` preservation branch and its regression test in the same PR; a flag opening a new section adds per-field `IsDefined` merge branches — never whole-struct assignment — plus a hand-written merge test (the `mergeSessionSleep` + `daemon.formula_v2` pattern), written when that flag actually appears.* + +### 4.3 Load-time enum validation: a typo can never mean "off" + +The accessor's `"" → default` mapping is safe only if the accessor never sees an unvalidated non-empty value. Today it would: nothing in `internal/config` validates enum *values*. The in-tree precedent is itself the bug — `NormalizedBDCompatibility` (`config.go:1401`) silently maps any unknown value to `bd-1.0.4` via its `default:` case, and the validation its doc comment promises ("validation reports unknown values separately when loading user config") does not exist. Copying that idiom means `conditional_writes = "requre"` silently resolves to Off — a silent fallback on the exact knob whose design contract is "no silent fallback". + +The subsystem therefore ships a registry-driven, **hard-error** validation walk, run at config load beside the existing hard validator (`ValidateDoltConfig`, invoked at `compose.go:701` — deliberately *not* appended to the warnings-only `ValidateSemantics` stream at `compose.go:711`, because a mangled correctness mode must stop the load, not decorate it): + +```go +// internal/config/validate_rollout_flags.go + +// ValidateRolloutFlagValues rejects out-of-enum values for every registered +// rollout flag. Driven by the registry: each Spec.ConfigPath is resolved +// against cfg by the same reflection used in registry_test, so a new flag +// gets load-time validation with zero per-flag code here. +func ValidateRolloutFlagValues(cfg *City, source string, specs []rollout.Spec) error { + for _, spec := range specs { + raw := resolveConfigPath(cfg, spec.ConfigPath) // reflection over toml tags + if raw == "" || spec.Allows(raw) { + continue + } + return fmt.Errorf( + "%s: [%s] %s: invalid value %q (allowed: %s)", + source, spec.Section(), spec.Field(), raw, strings.Join(spec.AllowedValues(), ", ")) + } + return nil +} +``` + +Failure is fatal and names all three things the operator needs: + +``` +city.toml: [beads] conditional_writes: invalid value "requre" (allowed: off, auto, require) +``` + +Contract points: + +- **Every registry flag gets this for free.** The walk iterates Specs; there is no per-flag validation code to forget. For `Mode` flags the allowed set is `off|auto|require`; `*bool` kill-switches are type-checked by TOML decoding itself and skip the walk. +- **Accessors stay total but never exercised on garbage.** `ConditionalWritesMode` keeps a trivially total mapping, but validation guarantees the non-empty input is a member of the enum before any accessor runs. No `default:` case that quietly picks a winner. +- **Validation runs on the merged result**, after `mergeFragment` and patches — so a bad value introduced by *any* layer (pack, fragment, patch) is caught, and a good value destroyed by a merge bug is exercised by §4.2's tests rather than masked here. +- **jsonschema enum tags remain doc-gen only.** They feed the generated schema and editor tooling; they are not, and have never been, runtime enforcement. The walk is the runtime tooth. + +**Same-PR bugfix:** `bd_compatibility` joins the walk as a validated enum field (it is not a rollout Spec; the validator additionally accepts a small static list of pre-existing enum-valued fields, of which `bd_compatibility` is the first). `NormalizedBDCompatibility`'s `default:` case becomes defensively unreachable, and its doc-comment claim becomes true instead of aspirational. Fixing the cited precedent in the same PR matters: the next flag author will copy whatever `bd_compatibility` does. + +### 4.4 What each layer catches + +| Failure mode | Caught by | +| --- | --- | +| Typo'd key (`conditional_write = ...`) | `undecoded.go` unknown-key fatal + suggestion (existing) | +| Typo'd value (`"requre"`, `"Require"`, `"required"`) | §4.3 fatal enum walk at load | +| Fragment sibling key wipes the flag | §4.2 preservation branch + hand-written regression test | +| Field renamed without registry update (or vice versa) | registry_test ConfigPath reflection check | +| Accessor default drifts from Spec.Default | registry_test zero-value-City equality assertion | +| Deliberate fragment override of the flag | last-writer-wins preserved; §4.2 companion test pins it | + +Nothing in this section adds new merge machinery, new provenance plumbing, or a parallel config surface: one field, one accessor, one merge branch mirroring an in-tree template, two hand-written tests, and one registry-driven validator that every future flag inherits. + +## 5. Resolution: precedence, origin, env break-glass, latching + +### 5.1 Precedence + +Resolution is a strict five-layer stack. The first three layers are what `rollout.Resolve` computes; the last two are deliberately *not* precedence layers inside the resolver — one is a downstream veto, one is structural. + +| # | Layer | Wins when | Reported `Origin` | +|---|-------|-----------|-------------------| +| 1 | Builtin default | Flag absent from every config layer and env (`Spec.Default`, mirrored by the accessor's `""` mapping) | `builtin` | +| 2 | Merged config | Key present in any layer of the **existing** pack → city → fragment → patch chain. Resolve receives the already-merged `*config.City`; the compose pipeline is untouched by this design (per-field merge preservation is section 4's concern) | `config` | +| 3 | Env override | `Spec.EnvOverride != ""`, the var is set, the value parses, and `Spec.EnvSemantics` permits it to apply (5.5) | `env` | +| 4 | Runtime capability veto | Per resolved store, at the beads factory / consumption seam (section 7). **A veto can only lower effective behavior, never raise it**: `off` stays `off` on a fully capable store (no auto-enable), `auto ∧ ¬capable` degrades loudly, `require ∧ ¬capable` refuses. Capability never rewrites the resolved mode — it ANDs with it downstream, which is why it is not an `Origin` value | — (surfaces as DEGRADED / FAIL-CLOSED, not as an origin) | +| 5 | Test override | Tests build the `Flags` value directly via `rollout.ForTest(t, rollout.WithBeadsConditionalWrites(rollout.Require), ...)` and never call `Resolve`. There is no global for an override to fight, so no precedence conflict is expressible (section 9) | — | + +Reality vetoes intent; it never quietly wins. Intent (layers 1–3) is what doctor reports as the resolved mode; the veto is reported separately, per store. + +### 5.2 `Resolve`: signature and invocation contract + +```go +package rollout + +type ResolveOptions struct { + // LookupEnv is injected for testability; nil means os.LookupEnv. + // No env read ever happens at package init (TestNoLeakVectorReadsAtPackageInit). + LookupEnv func(key string) (string, bool) +} + +// Resolve computes the immutable Flags value for this process from the +// already-merged config plus env overrides. It returns an error — and the +// caller MUST treat it as fatal at startup — when an env override on a +// correctness-category flag is unparseable (5.4). +func Resolve(cfg *config.City, opts ResolveOptions) (Flags, error) + +// Flags is an immutable value. Notices produced during resolution are +// retained on it for the life of the process (5.6) and rendered by +// doctor/status; they are never only a startup stderr line. +func (f Flags) Notices() []Notice +func (f Flags) Origin(key string) Origin +``` + +`Resolve` is folded into the shared config loaders (`loadCityConfig` and its `loadCityConfig*` variants in `cmd/gc/cmd_agent.go`), so `cfg` and `Flags` travel together as one value into every command path — resolution correctness does not depend on per-command discipline across the ~30 load sites. (Threading from there into stores is section 6; this section only pins that there is exactly one `Resolve` call per process, at config-load time.) + +### 5.3 Origin: three values, honestly scoped + +```go +type Origin string + +const ( + OriginBuiltin Origin = "builtin" // field zero-valued everywhere, env unset + OriginConfig Origin = "config" // field set in the merged config, env unset/inapplicable + OriginEnv Origin = "env" // env override applied +) +``` + +These are the only three values recoverable from `Resolve`'s inputs with **zero loader changes**: the merged `config.City` plus one env lookup. Per-layer provenance (`pack` vs `city` vs `fragment`) does not exist in the compose pipeline today — `mergeFragment` is destructive last-writer-wins and `Provenance` tracks only imports/agents/rigs — so claiming finer origins would require new `compose.go` plumbing for a diagnostic nicety. We defer it (and the `/v0/config/explain` extension that would render it) until an operator actually asks "which fragment set this," and cost it as new provenance plumbing then. `builtin|config|env` answers the one break-glass audit question that matters: *is a forgotten env var pinning this flag?* + +Origin travels with the value: every refusal, degrade diagnostic, doctor row, and (later) status-wire entry carries `mode` + `origin` together, e.g. `conditional_writes=off (env: GC_BEADS_CONDITIONAL_WRITES)`. + +### 5.4 Env grammar: mode names only, fail-fast on garbage + +Each Spec declares at most one override var (`Spec.EnvOverride`, `GC_*`-prefixed, registered in `testenv.LeakVectorVars` — enforced by a registry test so a live agent-session value can never leak into test processes). + +**Grammar is per value-kind, and deliberately narrow:** + +- `rollout.Mode` flags accept **only the literal mode names**: `off`, `auto`, `require`. No truthy spellings — `1`, `true`, `on`, `yes` are all parse errors for a tri-state. A boolean spelling cannot express which of three states the operator meant, and a typo'd truthy value must never be able to downgrade `require` silently. +- `*bool` kill-switch flags accept `strconv.ParseBool` spellings. + +**Failure behavior splits by category:** + +- **Correctness categories (`infra-rollout`, `infra-migration`): unparseable env value ⇒ the process refuses to start.** `Resolve` returns an error naming the variable, the raw value, and the accepted grammar: + + ``` + rollout: GC_BEADS_CONDITIONAL_WRITES="disable" is not a valid value; accepted: off|auto|require + ``` + + Rationale: the env var on these flags exists as break-glass (5.5). A break-glass that silently no-ops at 2am — one ignored warning line in a journal nobody is tailing while the operator believes the flag flipped — is a failed break-glass. Starting in the wrong mode is strictly worse than not starting. +- **`infra-killswitch`**: an unparseable value records an `invalid-env-ignored` Notice and keeps the config-resolved value (the existing `GC_EVENTS_ROTATION_ENABLED` behavior at `cmd/gc/providers.go:998-1002`). Kill-switches gate non-correctness machinery; refusing startup over them is disproportionate. + +This is the *env* grammar only. Invalid values in **config** never reach `Resolve` at all: load-time enum validation (section 4) rejects them fatally, so accessors and the resolver only ever see `""` or a validated member. + +### 5.5 `EnvSemantics`: per-Spec precedence, no retroactive changes + +```go +type EnvSemantics string + +const ( + EnvOverrides EnvSemantics = "overrides" // env beats explicit config (break-glass); default for new flags + EnvFillsNil EnvSemantics = "fills-nil" // env applies only when config left the field unset +) +``` + +The codebase today ships both precedences and they contradict each other: `GC_DOLT_AUTO_GC_ENABLED` fills only when config is nil (`cmd/gc/dolt_start_managed.go:973` — explicit config wins), while `GC_EVENTS_ROTATION_ENABLED` overrides. We do **not** "unify" these as a migration side effect. Absorbing a legacy flag into the registry preserves its existing precedence via its Spec's `EnvSemantics` — `GC_DOLT_AUTO_GC_ENABLED` registers as `fills-nil` — because flipping a live operator's precedence silently is exactly the class of behavior change this subsystem exists to prevent (an operator with `auto_gc_enabled = false` in `city.toml` and a stale `=1` in a supervisor wrapper would get auto-GC re-enabled on upgrade with zero config diff). Unifying a legacy flag onto `overrides` is a separate, release-noted breaking change with a doctor callout, never a migration footnote. + +New flags default to `overrides`, because for them env exists only as break-glass, and a break-glass that cannot override explicit config isn't one. + +**The CAS flag keeps its env var, with a named consumer.** `GC_BEADS_CONDITIONAL_WRITES` (`overrides`) is justified not by local operators — for a process-latched flag, exporting a var and restarting costs the same as editing `city.toml` and restarting — but by hosted cities with baked, immutable config (the crucible deployment model), where the unit environment is the only injectable surface. That consumer is real today; the var ships in slice 1. + +```toml +# city.toml — the operator's declared intent +[beads] +conditional_writes = "require" +``` + +```bash +# incident break-glass in the controller's unit env — wins, per-process, until restart-with-cleanup +GC_BEADS_CONDITIONAL_WRITES=off +``` + +### 5.6 Env-contradicts-config is push-loud, and Notices outlive startup + +When a **valid** env override changes the value of a flag that is **explicitly set in any config layer** (origin would have been `config`), `Resolve` does three things: + +1. Records an `env-override-contradicts-config` Notice on the `Flags` value, carrying key, config value, env value, and var name. +2. The composition root emits a **startup structured log line** echoing the effective resolution: `conditional_writes=off (env GC_BEADS_CONDITIONAL_WRITES) overriding explicit city.toml value "require"`. +3. The daemon fires a **typed registered event** (`events.RegisterPayload`) so the divergence lands in event history and is alertable — not merely discoverable by an operator who thinks to run doctor. + +Env override set but config silent (origin would have been `builtin`) records a plain `env-override-active` Notice with no event — nothing was contradicted. + +**Notice lifetime is part of the contract**: `Resolve`'s Notices live on the `Flags` value held by `controllerState` for the whole process lifetime, and doctor/status render them verbatim. A journal rotation three weeks after boot must not erase the only record of *why* the effective mode is what it is. Boundary test: start with a contradicting env var, query doctor against the running daemon, assert the notice renders. + +**Break-glass scope is per-process, and we say so.** `gc` is a multi-process system (controller daemon, agent-invoked `gc hook --claim`, supervisor children with curated env); an env override affects only the process that reads it. The supported whole-city change is config edit + restart. We make cross-process divergence *visible* rather than impossible: every refusal and degrade diagnostic carries `mode` + `origin`, so a controller writing at `off (env)` while a CLI path refuses at `require (config)` is attributable from the first log line of either side. (Restricting `EnvOverride` to the daemon entry point was considered and rejected for v1 — it complicates the resolver with entry-point awareness for a divergence the above already surfaces; revisit if a real bifurcated incident occurs.) + +### 5.7 Latching: v1 is process-latched, everywhere + +**Every flag in v1 latches at process start. There is no `Latch` field on `Spec`** — it was cut as YAGNI: no reload-tolerant flag exists yet, and a per-flag latch axis would ship untested machinery whose failure mode is the exact corruption the CAS flag prevents. + +The operational definition, pinned so the reload path cannot subvert it: + +- `controllerState` retains the boot-resolved `Flags` value. +- The config hot-reload path (`controllerState.loadCurrentConfigSnapshot`, `cmd/gc/api_state.go:1803`) **carries the boot snapshot forward into every later-constructed component**. It never hands a re-`Resolve`d mode to a store or consumer constructed after reload. Stores are born lazily and continuously in the controller (per-rig stores, drain member stores); without whole-process latching, one routine `city.toml` edit would put a legacy (unconditional) writer and a CAS writer inside the same process racing on `gc.control_epoch` — the precise mid-run mode flip the latch exists to make impossible. "Epoch-fence semantics never change under in-flight work" is only true if *process* is the latching unit. +- When the on-disk config now diverges from the latched value, the reload records a persistent `pending-restart` Notice: + + ``` + pending restart: conditional_writes require (city.toml) != off (latched at start) + ``` + + surfaced as a **doctor WARNING** and, once the status wire lands (section 10), on the wire. The operator learns the edit did not take effect *and* what will change on the next restart — no silent divergence between file and behavior in either direction. +- `ResolveOptions` (the injected `LookupEnv`) threads into the reload seam, so reload behavior is unit-testable with a map-backed fake and no `t.Setenv`. + +**Regression test (ships with the subsystem, not with the first consumer):** boot with `conditional_writes = "off"`, rewrite `city.toml` to `"require"`, trigger `loadCurrentConfigSnapshot`, construct a new store through the factory, assert the store receives `Off` and the `pending-restart` Notice fired. + +When a concrete reload-tolerant flag eventually exists, reload semantics come back as a designed feature — with per-component snapshot-generation visibility so doctor can never report a value a still-running component provably isn't using. Until then, restart is the only mode transition, and that is a feature. + +## 6. Caller API and threading + +The failure mode this section exists to kill is *wiring drift*: a resolution API that is correct in every unit test but skipped on one production path, silently yielding the zero value. cmd/gc has no `run()` choke point — config is loaded independently at ~30 sites (`cmd_hook.go`, `cmd_sling.go`, `cmd_formula.go` ×6, `beads_provider_lifecycle.go` ×4, `apiroute.go`, ...), which is exactly how `applyFeatureFlags` grew 8 scattered call sites. So the design does not ask commands to remember anything. Resolution happens inside the shared loaders, the mode is stamped where stores are born, and consumers hold no mode at all. + +### 6.1 Resolve lives in the shared config loaders + +`rollout.Resolve` is folded into the loader family in `cmd/gc` (`loadCityConfig`, `loadCityConfigFS`, `loadCityConfigWithBuiltinPacks`, `loadCityConfigWithoutBuiltinPackRefresh*`, `loadCityConfigForEditFS`, `loadCityConfigAllowMissingProviderReferences` — all funnel through one internal helper). The loader return signature changes so cfg and Flags travel as one value: + +```go +// cmd/gc — the ONLY production call path into rollout.Resolve. +func loadCityConfig(cityPath string, warningWriter ...io.Writer) (*config.City, rollout.Flags, error) + +func loadCityConfigWithBuiltinPacks(cityPath string, includes ...string) (*config.City, rollout.Flags, *config.Provenance, error) +``` + +Changing the arity is the enforcement mechanism: the compiler visits every one of the ~30 load sites in the migration PR, and no future load site can come into existence without deciding what to do with `Flags`. Paths that provably construct no stores (config-edit tooling) discard it with `_`; everything else threads it. Production passes `ResolveOptions{}` (nil `LookupEnv` → `os.LookupEnv`); the reload seam threads an injected `LookupEnv` (section 8). Resolve's `[]Notice` is retained **on** the returned `Flags` value — not printed-and-dropped — so doctor and the status wire can render origin/invalid-env/pending-restart facts for the life of the process. + +`api.Server` receives the boot-resolved `Flags` through its `State` at construction and never re-resolves. It reads `state.Flags()` for *rendering only* (status wire, section 11); the mode itself acts below the API layer, at store construction. The `syncFeatureFlags(state.Config())` calls at `server.go:197/203` are the named dual-root anti-pattern this retires (deleted in stage 5); the CAS flag never acquires a second resolution root at all. + +### 6.2 `rollout.Flags`: immutable value, one typed accessor per flag + +```go +package rollout + +// Flags is an immutable snapshot of every registered flag, resolved once +// per process at config load. It is a value type: copy it, thread it, +// never point at it from a package-level variable. +type Flags struct { + beadsConditionalWrites resolved[Mode] // {value Mode; origin Origin} + formulaV2 resolved[bool] + notices []Notice +} + +func (f Flags) BeadsConditionalWrites() Mode // typed; no string keys anywhere +func (f Flags) OriginOf(key string) Origin // builtin | config | env (doctor/status only) +func (f Flags) Notices() []Notice +``` + +One exported accessor per flag, generated alongside a paired `rollout.WithBeadsConditionalWrites(Mode)` ForTest option (section 9). No `Get(key string)`, no map. Deleting a flag deletes its accessor and its With\* option, and the compiler finds every consumer — production and test corpus alike. Flag removal is a compile-enforced operation, which is the anti-rot property the boilerplate buys. + +There is no package-level state behind any of this: no `atomic.Bool`, no `SetX()`, no `sync.Once` holding values. The `formula.SetFormulaV2Enabled` / `molecule.SetGraphApplyEnabled` global-setter bridge (`compile.go:632`, `graph_apply.go:30`) and the `formulatest.LockV2ForTest` mutex are the anti-pattern this deletes; the stage-1 freeze test (section 10) prevents new recruitment while stage 5 migrates them. + +### 6.3 One home for the mode: the beads factory stamps every store + +The conditional-writes mode has exactly one production home — `OpenStoreAtForCity` (`internal/beads/factory.go:77`). `StoreOpenOptions` gains the field; the factory stamps it onto every store it opens: + +```go +type StoreOpenOptions struct { + ScopeRoot string + CityPath string + Provider string + // ... existing fields ... + + // ConditionalWrites is the resolved city-global mode, stamped onto + // every store this open produces. Latched for the store's lifetime. + ConditionalWrites rollout.Mode +} +``` + +Every store type in `internal/beads` (BdStore, FileStore, MemStore, ExecStore, NativeDoltStore) carries the stamped mode as unexported instance state set at construction; `CachingStore` delegates to its backing store. There is **no** caller-facing `WithConditionalWrites` option — that shape is deliberately inexpressible. With a per-store option plus a mode parameter on the seam, tests could wire `store=Require / seam=Off`, a state production can never reach; with factory stamping and a parameterless seam, the divergence cannot be written down. + +`rollout.Mode`'s zero value is `ModeUnset`, distinct from `Off`. The factory maps unset → `Off` **and** records it in the store-open `BeadsDiagnostic` (`PreflightGate: "conditional_writes", PreflightReason: "mode not threaded; defaulted to off"`). An unthreaded open path therefore behaves exactly like today's default — it can never *raise* enforcement — but it is visible in doctor and greppable in tests rather than silently indistinguishable from a deliberate `off`. + +### 6.4 `ResolveConditionalWriter(store)`: nothing to pass, nothing to get wrong + +```go +// internal/beads. The single tested composition point of policy × capability. +// The mode is read from the store's factory stamp; there is no mode +// parameter, so callers cannot contradict the store. +func ResolveConditionalWriter(store Store) (ConditionalWriter, *BeadsDiagnostic, error) +``` + +Return contract (semantics detailed in section 7): `Off` → `(nil, nil, nil)`, caller takes the byte-identical legacy path; `Auto`∧capable → writer; `Auto`∧incapable → `(nil, diagnostic, nil)` with the once-latched degrade event; `Require`∧incapable → typed error, fail closed. The stamp is read through an unexported interface implemented by every store type (compile-asserted with `var _`); wrappers forward it. Because the interface is unexported, only `internal/beads` can implement it — no consumer can synthesize a differently-moded store. + +### 6.5 What each layer holds + +| Layer | Holds | Never holds | +|---|---|---| +| cmd/gc loaders | `cfg` + `Flags` (resolved once, together) | — | +| beads factory | `Mode` (from `Flags`, stamped per store) | the full `Flags` | +| stores | latched mode, instance state, dies with the store | config, env | +| dispatch / molecule / API consumers | store handles (`graphBeadStore()`, `drainMemberOwningStore(member)`) | any mode value | +| `api.Server` | boot `Flags` snapshot, render-only | a re-resolve path | + +The payoff of the bottom two rows: C4 and C6 call `ResolveConditionalWriter` on whatever store they already hold. They cannot be handed the wrong mode because they are never handed a mode. Per-store capability heterogeneity (sqlite graph store vs. a rig's bd store) is handled where it exists — on the store — not threaded through consumer options. + +### 6.6 Entry-point tests: the wiring is the contract + +Seam tests prove the seam; they say nothing about whether a command reached it (the `routeReadCmd` lesson). Stage 1 lands one entry-point test per CAS-relevant command — **controller, hook, sling, api server** — each asserting that `require` in a real temp `city.toml` is observed at the bd wire by a probe write: + +```toml +# t.TempDir() city +[beads] +conditional_writes = "require" +``` + +```go +func TestHookClaimObservesConditionalWritesRequire(t *testing.T) { + cityDir := writeTempCity(t, requireCityToml) + runner := newRecordingRunner(t, + withHelpAdvertising("--if-revision"), // capability probe passes + ) + // Drive the real command entry point (not the seam) against cityDir, + // store construction routed through the factory with the fake runner. + runHookClaim(t, cityDir, runner) + + argv := runner.lastWriteArgv() + if !slices.Contains(argv, "--if-revision") { + t.Fatalf("hook claim wrote unconditionally under require: %q", argv) + } +} +``` + +These four tests are the regression net for the exact bug class the loader-folding and factory-stamping exist to prevent: a command path that loads config but drops `Flags`, or opens a store outside the factory. Any such path fails here — with `require` visibly not observed — instead of shipping as a silent `Off` writer against a fleet whose config promises fencing. + +## 7. Testability + +The subsystem is testable by construction, not by discipline. Every seam is per-instance and typed; there is no package-level `atomic.Bool`, no `SetX()`, no save/restore idiom, no `t.Setenv`, and no state a parallel test can observe from a sibling. This section specifies the five seams, the conformance suite that keeps fakes honest, and the named regression tests that are merge gates. + +### 7.1 Value seam: `rollout.ForTest` with typed `With*` options + +Tests never call `Resolve`. They construct the immutable `Flags` value directly: + +```go +// internal/rollout/fortest.go + +// ForTestOption sets one flag on a Flags value under construction. +// Exactly one With* constructor exists per registered flag, generated +// alongside the flag's Flags accessor in the same file. +type ForTestOption func(*flagsBuilder) + +// ForTest builds Flags from the canonical registry's defaults plus +// explicit typed overrides. +func ForTest(tb testing.TB, opts ...ForTestOption) Flags + +// WithBeadsConditionalWrites overrides beads.conditional_writes. +func WithBeadsConditionalWrites(m Mode) ForTestOption +``` + +```go +flags := rollout.ForTest(t, rollout.WithBeadsConditionalWrites(rollout.Require)) +store := beadstest.OpenMem(t, beadstest.WithStampedMode(flags.BeadsConditionalWrites())) +``` + +Properties this buys, each deliberate: + +- **Compile-time flag removal.** Deleting a flag deletes its `Flags` accessor *and* its `With*` option in the same file. The compiler then finds every production call site **and every test**. There is no string-keyed override path, so the "forty tests fail one by one at runtime with unknown-key errors" cleanup mode does not exist. +- **Structural isolation.** `Flags` is a value handed to the constructor under test. Two `t.Parallel` tests with opposite modes cannot observe each other because nothing is process-scoped. This retires the pattern it replaces: the ~20 save/restore blocks in `molecule_test.go` and the `formulatest.LockV2ForTest` serializing mutex exist only because `SetFormulaV2Enabled` is a package global (deleted in stage 5). +- **No registry mutation from subsystem tests.** The canonical `[]Spec` is unexported behind a read-only accessor. The registry validator and the `Flags` builder both take a `[]Spec` parameter, so `internal/rollout`'s own tests (e.g. "validator rejects a Spec with no Owner") construct **local synthetic registries** as local values: + +```go +func TestValidatorRejectsMissingOwner(t *testing.T) { + t.Parallel() + specs := []rollout.Spec{{Key: "x.y", Category: rollout.InfraRollout /* no Owner */}} + err := rollout.ValidateSpecs(specs) + // ... +} +``` + +A panicking or forgetful test can never leak a phantom Spec into a parallel sibling's `ForTest` defaults, because there is no shared slice to append to. + +### 7.2 Resolver seam: injected `LookupEnv`, `LeakVectorVars` enforced + +`Resolve` never touches `os.LookupEnv` directly: + +```go +type ResolveOptions struct { + // LookupEnv defaults to os.LookupEnv when nil. Tests inject a + // map-backed fake; no test in the repo calls t.Setenv for a flag var. + LookupEnv func(key string) (string, bool) +} +``` + +```go +env := map[string]string{"GC_BEADS_CONDITIONAL_WRITES": "require"} +flags, notices, err := rollout.Resolve(cfg, rollout.ResolveOptions{ + LookupEnv: func(k string) (string, bool) { v, ok := env[k]; return v, ok }, +}) +``` + +Unit tests against the map fake cover the full precedence and grammar matrix without process-env mutation (which would also panic under `t.Parallel`): + +| Case | Assertion | +|---|---| +| env unset, config unset | default; Origin `builtin` | +| env unset, config `require` | `Require`; Origin `config` | +| env `off`, config `require` | `Off`; Origin `env`; env-contradicts-config startup log + typed event emitted | +| env `1` / `true` / `disable` on a Mode flag | `Resolve` returns an error (startup fails fast) naming var, raw value, and the `off\|auto\|require` grammar | +| env set, flag has `EnvSemantics: fills-nil`, config explicitly set | config wins (legacy-precedence preservation, tested per absorbed flag) | + +Two enforcement tests close the leak vectors: + +- **`LeakVectorVars` registration.** `GC_BEADS_CONDITIONAL_WRITES` is registered in `internal/testenv`'s `LeakVectorVars`, so the testenv gate scrubs it from every test process — a live agent-session export can never silently flip a test's resolution. A registry test asserts the invariant generically: *every non-empty `Spec.EnvOverride` appears in `LeakVectorVars`*, so a future flag cannot forget it. +- **Frozen `GC_*` baseline.** The stage-1 inventory test fails on any new `os.Getenv`/`os.LookupEnv` site matching `"GC_"` outside testenv gates, registry `EnvOverride`s, and the checked-in baseline — so a shadow env flag cannot appear without a loud, reviewed baseline diff. + +### 7.3 Capability seam: instance toggles, never interface-stripping wrappers + +The `withoutConditionalWrites(store)` wrapper pattern is **banned**. A wrapper struct hides *every* optional interface, not just the one under test — `internal/beads` has at least five type-asserted capabilities (`ConditionalAssignmentReleaser`, `AtomicTxStore`, `StorageCreateStore`, `StorageGraphApplyStore`, `ParentProjectionWaiter`), and `class_store.go:15` already documents the in-tree bite: optional interfaces are not promoted through embedding. A test meaning to flip one axis would silently flip five, and e.g. `CachingStore`'s graph-apply fallback would take a branch production never pairs with CAS-incapable stores. + +Instead, capability absence is a per-instance field on the fakes: + +```go +// internal/beads/mem_store.go +type MemStore struct { + // DisableConditionalWrites makes every ConditionalWriter method + // return ErrConditionalWriteUnsupported while the interface set — + // including all other optional capabilities — stays intact. + DisableConditionalWrites bool + // ... +} +``` + +`FileStore` gets the identical toggle. This drives the `auto`-degrade and `require`-fail-closed matrix cells deterministically: + +```go +mem := beadstest.OpenMem(t, beadstest.WithStampedMode(rollout.Auto)) +mem.DisableConditionalWrites = true +w, diag, err := beads.ResolveConditionalWriter(mem) +// assert: w == nil, diag.PreflightGate == "conditional_writes", err == nil (loud degrade) +``` + +Capability-absent-*by-interface* (a store type that genuinely lacks the methods) is tested only where it is real, with a purpose-built minimal store type in the test file — never by wrapping a full-featured store. + +Note the shape `ResolveConditionalWriter(store)` — **no mode parameter**. The mode is stamped onto the store by the factory (section 5), and tests stamp it through the same entry point (`beadstest.WithStampedMode`, which calls the factory's internal stamping path). The formerly-possible contradiction — store constructed at `Require`, seam called with `Off` — is now a state tests *cannot express*, so the suite can no longer accumulate green coverage of unreachable production states. + +### 7.4 Classifier and ambiguity tests: one seam, the fake `CommandRunner` + +There is exactly one injection point for everything bd-shaped: the store's existing injected `CommandRunner` (the `bdReadyProjectionEnabled` shape — `s.runner(s.dir, "bd", ...)`). The lazy capability probe, the exit-code classifier, and the CAS retry policy all run through it. There is **no** `WithBDCapabilityProbe`; with a single seam, a fake probe and a fake runner can never contradict each other, and the previously-possible "capable probe, exit-13 runtime" hybrid is unconstructable. + +The fake is a scripted runner keyed on argv: + +```go +type scriptedRunner struct { + t *testing.T + calls []scriptedCall // matched in order or by argv predicate +} + +type scriptedCall struct { + match func(args []string) bool + stdout string + exit int // 0 = success + err error // non-ExitError transport failures (i/o timeout, broken pipe) + apply func() // mutates fake backing state BEFORE returning err — "committed but ambiguous" +} +``` + +The classifier unit-test table, every row driven through this one fake: + +| Scripted bd behavior | Required classification | +|---|---| +| exit 9, stdout `{"code":"precondition_failed","expected_revision":4,"current_revision":7}` | `PreconditionFailedError{Expected:4, Current:7}` | +| exit 9, JSON body surrounded by log noise | same — defensive parse tolerates surrounding text | +| exit 13, body `code == "conditional-write-unsupported"` | `ErrConditionalWriteUnsupported`; per-store latch trips (assert a second write skips `--if-revision` classification and reports latched) | +| exit 13, no body / other body code (the beads#3734 close-authority shape) | typed **non-latching** refusal attributed to that write; latch NOT tripped (assert next write still attempts CAS) | +| usage/unknown-flag error mentioning `--if-revision` (what pre-#4682 bd actually emits) | `ErrConditionalWriteUnsupported`; latch trips | +| transport error (`i/o timeout`) with `apply` executed — the write committed | ambiguity contract engages: retry path MUST self-win-check on re-read before concluding loss; asserting a raw re-CAS with the stale expected revision fails the test | +| repeated unrelated-key revision churn during the emulation loop | bounded attempts + backoff, then the typed exhaustion error — distinct from `PreconditionFailed` — surfaces; the loop never spins unbounded | + +Probe-specific assertions on the same fake: + +- **Laziness:** constructing the store issues zero runner calls; the first conditional write triggers the four-verb help probe (`update`/`close`/`assign`/`delete` — a mid-merge dev bd can support one but not another); the second write issues no probe (memoized under the store mutex, the `readyProjectionChecked` idiom). +- **Nothing persisted:** no test may assert on any on-disk probe artifact, because none exists; a fresh store re-probes (no-status-files). + +### 7.5 The `ConditionalWriter` conformance suite: fakes that predict production + +Green in-process tests are worthless if `MemStore`'s revision discipline diverges from bd's (#4682's opaque per-bead nonce). A store that bumps revision only on `Update` while real bd bumps on *every* mutation including `assign` would train consumer retry loops to reuse stale revisions — exit-9 livelock in production, 100% green CI. The countermeasure is a store-agnostic conformance suite whose table **is** the interface contract, duplicated verbatim in the `ConditionalWriter` doc comment: + +```go +// internal/beads/beadstest/conformance.go + +// RunConditionalWriterConformance asserts the revision-discipline and +// CAS-semantics contract documented on beads.ConditionalWriter. +func RunConditionalWriterConformance(t *testing.T, open func(t *testing.T) beads.Store) +``` + +Rows (each a subtest): + +- **Revision bump discipline:** every mutation — update, close, assign, delete-adjacent metadata writes, label edits, `CompareAndSetMetadataKey` itself — bumps `Revision`; reads never do. +- **Exit-9 equivalence:** a stale expected revision yields `PreconditionFailedError` carrying both revisions, on every store, with identical semantics to bd's exit-9 body. +- **Empty-expected semantics:** `CompareAndSetMetadataKey(id, key, "", next)` claims only when the key is absent/empty; a set key yields `PreconditionFailed` with the current value recoverable by re-read. +- **Monotonicity:** revisions strictly increase per bead; no mutation ever reuses or decreases one. +- **Contention:** two goroutines racing one key — exactly one wins, the loser gets `PreconditionFailed`, never a silent double-apply. + +Execution matrix: + +| Store | Tier | +|---|---| +| `MemStore` | unit CI | +| `FileStore` | unit CI | +| `CachingStore` over `MemStore` | unit CI | +| sqlite graph store | unit CI (blocking deliverable of the C4/C6 PR) | +| `BdStore` against real bd | `//go:build integration`, slotted into the contract-test system (PR #3714) | + +The integration leg is the anchor: it is what makes the in-process rows *evidence* rather than self-consistent fiction. If bd's discipline changes, the integration run reds and the doc-comment contract plus all four fakes get updated in one reviewed diff. + +**Merge gate: the CachingStore livelock regression.** A MemStore-backed `CachingStore` test in the `ConditionalWriter` PR (stage 2, not deferred to C4): CAS succeeds, the post-write refresh `Get` is scripted to fail once, then a `PreconditionFailed` occurs — assert the cache entry was **evicted** (next `Get` hits the backing store and sees the fresh revision) in both paths, and that an exit-9 retry loop converges rather than re-failing forever on a locally-patched stale revision. This pins EVICT-never-patch against the existing `refreshBeadAfterWrite` optimistic-patch template, which a CAS port must not follow. + +### 7.6 Config, merge, and validation tests + +- **Accessor tests are pure struct construction** — no loader, no files: `BeadsConfig{ConditionalWrites: "require"}` asserts `ConditionalWritesMode() == rollout.Require`; the zero value asserts the default. A registry test generalizes the latter: for every Spec, a zero-value `config.City`'s typed accessor equals `Spec.Default` — closing the two-homes drift between `registry.go` and the accessor's `""` mapping (this is the test a half-landed graduation PR trips). +- **Hand-written fragment-merge regression, one per flag** (template: the `daemon.formula_v2` special case at `compose.go:1030-1047`): base layer sets `conditional_writes = "require"`; an included fragment defines only an unrelated `[beads]` sibling key (`prefix = "mc"`); assert the merged config still resolves `Require`. This is the test that keeps the whole-table-LWW footgun from silently downgrading a correctness opt-in through routine layering. Deliberately hand-written — the generic reflection merge harness is deleted (zero consumers, known `toml.MetaData` subtleties); a flag opening a *new* section owes its own hand-written test per the lifecycle doc. +- **Load-time enum validation:** `conditional_writes = "requre"` (and `"Require"`, `"required"`) fails config load with an error naming the field, the bad value, and the allowed set — asserted at the `ValidateSemantics` walk, so accessors are proven never to see an unvalidated non-empty value. + +### 7.7 The reload regression test (process-latch pinned by test) + +The mixed-mode-writers-after-reload corruption class gets a dedicated regression test at the controller-state level: + +```go +func TestConditionalWritesLatchSurvivesReload(t *testing.T) { + // 1. Boot controllerState with city.toml conditional_writes = "off". + // 2. Rewrite city.toml on disk to "require". + // 3. Trigger the reload path (loadCurrentConfigSnapshot, api_state.go:1808). + // 4. Construct a NEW beads store through the post-reload snapshot. + // 5. Assert the new store's stamped mode is Off (the boot-latched value), + // NOT the on-disk Require. + // 6. Assert a persistent Notice was recorded: + // "pending restart: conditional_writes require (city.toml) != off (latched at start)" + // and that doctor's rendering path classifies it as a WARNING. +} +``` + +`ResolveOptions` (with its injected `LookupEnv`) threads into the reload seam, so the reload path's env behavior is unit-testable with the same map-backed fake — without which reload env-precedence would be testable only via `t.Setenv`, which `LeakVectorVars` scrubbing deliberately defeats. + +### 7.8 Entry-point tests: threading completeness is tested where it breaks + +Seam tests cannot catch an un-threaded production path — the `routeReadCmd` lesson. Since cmd/gc has no single `run()` choke point (config loads at ~30 sites), each CAS-relevant entry point gets a test that goes in through the front door: + +```go +// Pattern, one per entry point: controller, gc hook --claim, gc sling, api server. +// 1. Temp city with conditional_writes = "require" in city.toml. +// 2. Invoke the command's real entry path (fake CommandRunner / capability- +// disabled store behind it). +// 3. Drive one probe write; assert it observes Require — either CAS argv +// (--if-revision) reaches the runner, or the typed fail-closed refusal +// surfaces. A silent legacy write fails the test. +``` + +These four tests are what make "resolution is folded into `loadCityConfig*` and stamped by the factory" a verified property instead of a design intention: a future command path that constructs a store without the shared loader gets the zero-value `Off` and reds the entry-point test for whichever surface it serves. + +### 7.9 Suite hygiene + +- **`testenv` import:** the new `internal/rollout` test package ships its generated `testenv_import_test.go` (`go run scripts/add-testenv-import.go`) in the same PR — the pre-push hook (`TestRequiresDedicatedTestenvImportFile`) rejects the push otherwise, and targeted `go test` runs will not surface it. +- **Doctor exit contract pinned:** a doctor-level test asserts FAIL-CLOSED (`require` ∧ incapable) and radar-surfaced past-due lifecycle items exit nonzero, and DEGRADED exits 0 — so monitoring integrations cannot drift. +- **Lifecycle tests are deterministic per commit:** the two-stage graduation test compares repo-pinned anchors in `deps.env`; nothing in the merge-blocking path compares against `time.Now()`, so a commit's pass/fail never changes without a diff and `git bisect` stays sound (wall-clock staleness lives in the non-blocking nightly radar). +- **What is deliberately absent:** no `t.Setenv`, no snapshot/restore helpers, no test mutex, no `SetXForTest` package function, no interface-stripping wrapper. If a test needs one of these, the production seam is wrong — fix the seam. + +## 8. ConditionalWriter: interface, classifier, and conformance + +This section defines the capability axis in `internal/beads`: the optional store interface, its typed errors, the BdStore exit-code classifier and probe, the bounded metadata-CAS emulation, the CachingStore eviction rule, and the conformance suite that pins one revision contract across every store. Mode resolution and consumer-side conflict semantics live in their own sections; everything here is store-level and mode-blind — a store either can do conditional writes or it cannot, and it reports which, loudly, in types. + +### 8.1 Interface and typed errors + +`ConditionalWriter` is a new optional interface in `internal/beads/beads.go`, modeled exactly on `ConditionalAssignmentReleaser` (beads.go:109) and discovered the same way: type-assert on the **resolved** store at the call site, never on a wrapper. + +```go +// ConditionalWriter is implemented by stores that can apply a write only when +// the caller's snapshot of the bead is still current. +// +// REVISION CONTRACT (normative — the conformance suite in +// conditional_writer_conformance_test.go executes this table against every +// implementing store, including real bd under the integration build tag): +// +// - Every bead carries an opaque int64 revision. Callers may test it only +// for equality; arithmetic, ordering across beads, and gap inference are +// all undefined. +// - EVERY mutation of the issue row bumps the revision: field updates, +// label add/remove, metadata writes (any key), assign, close, reopen, +// delete. Reads never bump. Cross-bead writes never bump this bead. +// - A bead's revision is monotonically increasing for the lifetime of the +// bead and is never reused. +// +// GRANULARITY CONTRACT: consumers may assume NEITHER value-level nor +// revision-level conflict semantics. Backends differ: sqlite and the native +// library implement CompareAndSetMetadataKey as server-side value-CAS +// (an unrelated-key write does not conflict); BdStore emulates it over +// --if-revision (an unrelated-key write CAN produce a spurious retry +// internally). Callers get the value-CAS RESULT either way, but must not +// build timing or interference assumptions on top of it. +type ConditionalWriter interface { + // UpdateIssueIfMatch applies opts only if the bead's revision equals + // expectedRevision; otherwise it returns *PreconditionFailedError. + UpdateIssueIfMatch(id string, expectedRevision int64, opts UpdateOpts) error + CloseIssueIfMatch(id string, expectedRevision int64) error + DeleteIssueIfMatch(id string, expectedRevision int64) error + + // CompareAndSetMetadataKey atomically sets metadata[key] = next iff the + // current value equals expected. expected == "" matches a key that is + // absent OR present with the empty value (the two states are + // indistinguishable to callers; release paths write "" to clear). + // Returns (true, nil) on swap, (false, nil) on a genuine value mismatch + // (the caller lost), and (false, err) for everything else. + CompareAndSetMetadataKey(id, key, expected, next string) (bool, error) +} +``` + +Typed errors, beside the existing sentinels in beads.go: + +```go +// ErrConditionalWriteUnsupported: this store (or the bd behind it) cannot do +// conditional writes. Latching this per store instance is the capability veto; +// no code path in internal/beads converts it into an unconditional write. +var ErrConditionalWriteUnsupported = errors.New("conditional writes unsupported") + +// PreconditionFailedError: the write was rejected because the revision moved +// (bd exit 9). Expected/Current come from bd's machine JSON body when +// parseable; zero otherwise (Raw preserves the body for forensics). +type PreconditionFailedError struct { + ID string + Expected int64 + Current int64 + Raw string +} + +// GateRefusalError: bd refused THIS write for a policy reason (exit 13 whose +// body code is anything other than conditional-write-unsupported — e.g. the +// beads#3734 close-authority guard). Per-write, never latches capability. +type GateRefusalError struct { + ID string + Verb string + Code string // machine body code, "" if absent + Raw string +} + +// CASRetriesExhaustedError: BdStore's bounded metadata-CAS emulation ran out +// of attempts under cross-key revision interference. Distinct from +// PreconditionFailedError: the caller did NOT lose the value race; the store +// could not get a clean shot. Consumers back off and re-enter level-triggered. +type CASRetriesExhaustedError struct { + ID, Key string + Attempts int + LastRevision int64 +} +``` + +Every implementing store carries a compile assertion (`var _ ConditionalWriter = (*BdStore)(nil)` etc.). Implementations in stage 2: **BdStore** (below), **MemStore** and **FileStore** natively (each with a `DisableConditionalWrites bool` instance toggle whose methods return `ErrConditionalWriteUnsupported` while the interface set stays intact — no hiding wrapper, per the class_store.go:15 optional-interface-promotion lesson), **CachingStore** by forwarding to `c.backing` (§8.5), **NativeDoltStore** by delegating to the beads library's ConditionalWriter (compile-time capability via go.mod), and the **sqlite graph store** as a single conditional `UPDATE ... WHERE revision = ?` (its own blocking deliverable; see the sqlite section). + +### 8.2 BdStore: argv building and the exit-code classifier + +All `--if-revision` argv construction stays inside `internal/beads` (`TestNoBdExecOutsideBeads` already forbids bd exec elsewhere). The runner already hands back stdout alongside the `*exec.ExitError` (bdstore.go's `classifyBDExecResult` path returns `out` even on failure), so the classifier is a pure function over `(out, err)`: + +| Signal from bd | Classification | Latches store incapable? | +|---|---|---| +| exit 9, stdout body parses to `{code, expected_revision, current_revision}` | `*PreconditionFailedError{Expected, Current}` | no | +| exit 9, body unparseable | `*PreconditionFailedError` with zero Expected/Current, `Raw` set | no | +| exit 13, body `code == "conditional-write-unsupported"` | `ErrConditionalWriteUnsupported` | **yes** | +| exit 13, any other or absent body code | `*GateRefusalError` (this write only) | no | +| usage/unknown-flag error mentioning `--if-revision` (what pre-#4682 bd actually emits — it exits with a generic usage error, never 13) | `ErrConditionalWriteUnsupported` | **yes** | +| `isBdAmbiguousWriteError` class (i/o timeout, broken pipe, conn reset) | returned as-is; the write MAY have committed — consumers apply their self-win contract (consumer-semantics section) | no | +| everything else | existing write-error classification (`isBdNotFound` → `ErrNotFound`, etc.) | no | + +Two rules are load-bearing: + +1. **The exit-13 latch is body-code-gated, not exit-code-gated.** bd has other write-authority gates on exit 13 (the close-authority guard is in production today). Latching on the bare number would convert one policy refusal into a process-lifetime silent degrade of every subsequent fenced write under `auto` — the exact clobber class CAS exists to prevent. A 13 without the machine body code is a per-write `GateRefusalError` and the store stays capable. +2. **Exit-9 body parsing is defensive.** Tolerate surrounding noise (the `extractJSON` idiom already used for `bd sql` output), and degrade to a zero-valued `PreconditionFailedError` rather than misclassifying — a precondition failure with unknown revisions is still a precondition failure. + +The classifier is exhaustively unit-tested through the injected `CommandRunner` fake: exit 9 with body, exit 9 with noise-wrapped body, exit 13 with the unsupported body code, bare exit 13, the old-bd `unknown flag: --if-revision` usage string, and an ambiguous error injected **after** the fake has committed the write. + +**Retry policy is dedicated and separate from the blind transient loop.** Conditional writes never route through `runBDTransientWrite`/`isBdTransientWriteError` (bdstore.go:1873): replaying a stale `--if-revision N` after a connection error is wrong (the first attempt may have committed and bumped the revision), and blind retry of exit 9 is worse (it converts a signal into a spin). The dedicated wrapper: connection/serialization-class errors re-read the bead's revision before any re-attempt; exit 9 is surfaced to the caller immediately (the caller re-reads and re-decides — that is the whole point of CAS); nothing is ever downgraded to an unconditional write. + +### 8.3 Capability probe: lazy, four-verb, one seam + +Capability has two axes that doctor renders separately: + +- **Probe verdict** — "does this bd parse `--if-revision`?", memoized once per store instance. +- **Runtime latch** — "did a real conditional write come back unsupported?", set by the classifier rows above. The latch is **authoritative over the probe** in both directions of skew (PATH drift, in-place downgrade). + +The probe runs through the store's **existing** `CommandRunner` — the same seam `bdReadyProjectionEnabled` uses (`s.runner(s.dir, "bd", "version")`, bdstore_ready_projection.go:69-88). There is deliberately **no** `WithBDCapabilityProbe` option: a second injection seam would let tests wire a capable-probe/incapable-runner hybrid that no deployment can produce. One fake runner controls probe output and per-call exit codes from one place, so probe/runtime consistency is structural in tests. + +```go +type BdStore struct { + // ... + condWriteMu sync.Mutex + condWriteProbed bool + condWriteCapable bool // probe verdict + condWriteLatched bool // runtime unsupported latch (authoritative) +} + +func (s *BdStore) conditionalWritesCapable() (bool, error) { + s.condWriteMu.Lock() + defer s.condWriteMu.Unlock() + if s.condWriteLatched { + return false, nil + } + if s.condWriteProbed { + return s.condWriteCapable, nil + } + // Lazy: reached on the FIRST conditional write, never at construction. + for _, verb := range []string{"update", "close", "assign", "delete"} { + out, err := s.runner(s.dir, "bd", verb, "--help") + if err != nil || !bytes.Contains(out, []byte("--if-revision")) { + s.condWriteProbed, s.condWriteCapable = true, false + return false, nil + } + } + s.condWriteProbed, s.condWriteCapable = true, true + return true, nil +} +``` + +Design points, each answering a specific red-team finding: + +- **Lazy, not construction-time.** Short-lived CLI paths (`gc hook`) open stores constantly; four `--help` subprocesses at every store open is an unacceptable tax for mode=off or read-only invocations. The probe fires on the first conditional write and is memoized under the mutex, mirroring `readyProjectionChecked`. +- **All four verbs.** The consumers use update, close, assign, and delete; a dev bd mid-merge of #4682 can support one but not another. A single-verb probe would report capable and then eat runtime refusals with doctor showing a clean probe. +- **Help-grep is the interim detector only.** The day beads tags the release containing #4682, the probe switches to `ProbeBDVersion` + `deps.CompareVersions` against a new `bdConditionalWritesMinVersion` anchor in deps.env (added under the `TestBDVersionPins` lockstep) — exactly the `bdReadyProjectionMinVersion` shape. The runtime latch stays authoritative either way; correctness never rests on a version string or help text alone. +- **Nothing is persisted.** Per "no status files — query live state", the probe result and the latch are instance state that dies with the store; a restart re-probes the live bd. Operators upgrading bd in place restart to re-evaluate — doctor's DEGRADED explanation says so explicitly. + +### 8.4 CompareAndSetMetadataKey on BdStore: bounded emulation, typed exhaustion + +bd's primitive is revision-CAS (`--if-revision N`); the interface promises value-CAS on one metadata key. BdStore emulates: read the bead, check the value, write the key under the observed revision. + +The hazard is **cross-key interference**: control and member beads are metadata-hot (controller_error stamps, attempt logs, heartbeats), so an unrelated-key write between the read and the CAS produces a spurious exit 9 even though nobody touched *our* key — and each retry costs a fresh ~100ms+ bd subprocess. The loop is therefore bounded, with a typed exhaustion error that consumers can distinguish from a genuine loss: + +```go +const ( + casEmulationMaxAttempts = 4 + casEmulationBaseBackoff = 25 * time.Millisecond // doubles per attempt, jittered +) + +func (s *BdStore) CompareAndSetMetadataKey(id, key, expected, next string) (bool, error) { + var pre *PreconditionFailedError + for attempt := 1; ; attempt++ { + b, err := s.Get(id) + if err != nil { + return false, err + } + if b.Metadata[key] != expected { // ""≡absent per the interface contract + return false, nil // genuine value loss: the caller lost the race + } + err = s.runConditionalWrite(id, b.Revision, + "update", id, "--set-metadata", key+"="+next, "--if-revision", strconv.FormatInt(b.Revision, 10)) + switch { + case err == nil: + return true, nil + case errors.As(err, &pre): // revision moved; value re-checked next lap + if attempt == casEmulationMaxAttempts { + return false, &CASRetriesExhaustedError{ID: id, Key: key, + Attempts: attempt, LastRevision: b.Revision} + } + sleepWithJitter(attempt) + default: + return false, err // unsupported / gate refusal / ambiguous: surface as-is + } + } +} +``` + +Exhaustion is **not** `PreconditionFailedError` and not `(false, nil)`: the value never mismatched, so telling the caller "you lost" would strand reservations (the C6 self-win contract depends on the distinction). Consumers treat exhaustion as a transient — back off and re-enter through the level-triggered pass. + +**Sidestep under evaluation (stage-2 spike, decided before C4/C6 land):** implement BdStore value-CAS as a single conditional SQL `UPDATE` — the `ReleaseIfCurrent` template at bdstore.go:1097, including its `releaseIfCurrentViaEmbeddedDoltSQL` fallback — with a JSON-path predicate on the metadata column. This eliminates cross-key interference entirely (the predicate tests the *value*, not the revision) at zero subprocess-retry cost. Disqualifier the spike must clear: the raw SQL path bypasses bd's write layer, so the same `UPDATE` must also bump the revision column itself (`revision = revision + 1`) or it breaks the revision contract for every other conditional writer; if bd's schema or the embedded fallback can't guarantee that atomically, the emulation loop remains the shipping implementation and the SQL path is dropped, not half-adopted. + +### 8.5 CachingStore: forward, and evict — never patch + +CachingStore implements `ConditionalWriter` by type-asserting `c.backing` and forwarding, following the `ReleaseIfCurrent` template at caching_store_writes.go:138 (`ErrConditionalWriteUnsupported` when the backing store doesn't implement it). The cache-maintenance rule diverges from the existing template on purpose: + +The existing write path refreshes the bead after a successful write and, when that refresh fails transiently, **optimistically patches** the cached clone. A CAS port of that fallback is poison: the local patch cannot synthesize the new revision, `CachingStore.Get` serves the cached clone, and every consumer's exit-9 recovery then re-reads the **stale** revision through the cache and re-fails — a livelock indistinguishable from real contention. + +Rule: **evict, never patch.** + +- CAS success + successful refresh → refresh the cache entry (normal path). +- CAS success + **failed** refresh → `delete(c.beads, id)` (and the deps/dirty bookkeeping), forcing the next `Get` to the backing store. +- **Every** `PreconditionFailedError` from the backing store → evict the entry too. The cached revision is proven stale by construction; keeping it guarantees the caller's re-read feeds the next attempt the same dead revision. + +The MemStore-backed CachingStore regression test — CAS succeeds, refresh is forced to fail, assert the next Get hits the backing store and a retry loop converges instead of livelocking; plus the PreconditionFailed-evicts case — is a **merge gate of the stage-2 ConditionalWriter PR**, not a pre-C4 follow-up. + +### 8.6 Conformance suite: one contract, every store + +The revision contract in §8.1's doc comment is only worth what enforces it. The single named failure mode: real bd bumps revision on *every* mutation (assign included); a fake that bumps only on Update trains consumer retry loops to reuse stale revisions after an interleaved assign — green CI, exit-9 livelock in production. So the contract is executable: + +```go +// internal/beads/conditional_writer_conformance_test.go +func RunConditionalWriterConformance(t *testing.T, name string, open func(t *testing.T) Store) { + t.Run(name+"/every_mutation_bumps_revision", ...) // update, labels, metadata, + // assign, close, reopen — full verb matrix + t.Run(name+"/reads_never_bump", ...) + t.Run(name+"/revision_monotonic_never_reused", ...) + t.Run(name+"/stale_revision_is_precondition_failed", ...) // typed, Expected/Current populated + // where the backend can supply them + t.Run(name+"/cas_empty_expected_claims_absent_or_empty_only", ...) + t.Run(name+"/cas_value_mismatch_is_false_nil_not_error", ...) + t.Run(name+"/cas_winner_value_visible_to_loser_reread", ...) + t.Run(name+"/disable_toggle_returns_typed_unsupported_with_interfaces_intact", ...) +} +``` + +Rows in the matrix: + +| Store | Tier | Notes | +|---|---|---| +| MemStore | unit CI | native implementation; also drives the `DisableConditionalWrites` row | +| FileStore | unit CI | native implementation | +| CachingStore over MemStore | unit CI | forwarding + both eviction cases (§8.5) | +| sqlite graph store | unit CI | the conditional-UPDATE implementation; same suite, no special-casing | +| BdStore against real bd | `//go:build integration` | the authority row; slots into the existing Beads↔GasCity contract-test system (PR #3714) so a bd version bump that changes bump discipline fails *here*, not in a production drain | + +The doc comment is normative and the integration row verifies bd complies with it; if a future bd diverges, the suite goes red and the contract is amended **consciously** — in the interface comment, in the fakes, and in every consumer's retry assumptions, in one reviewed diff. Divergent granularity (BdStore's emulation vs sqlite's value-CAS) is exercised by the suite only through the caller-visible result surface, matching the granularity contract: no conformance case may assert interference behavior the contract says is undefined. + +What deliberately does **not** exist in this section's deliverables: a `withoutConditionalWrites` wrapper (it would silently strip the other five optional store interfaces — the in-tree embedding lesson), a second probe seam, any persisted capability state, and any path — retry wrapper, classifier arm, cache fallback, or conformance shim — that turns `ErrConditionalWriteUnsupported` into an unconditional write. + +I have full grounding on the in-tree code. Writing the section now. + +## 9. CAS consumers: C4 epoch fence, C6 drain reservation, C2 API + +One knob (`[beads] conditional_writes`), three consumers, landed in code order: C6 and C4 ship together in stage 3 (they share the sqlite `CompareAndSetMetadataKey` blocking deliverable), C2 ships in stage 4 with the beads library bump. Each consumer gets a **written contract** in this section — not "re-read and converge" hand-waving — because the red-team demonstrated that every unspecified exit-9 path in these three call sites is a distinct correctness bug (stranded reservations, orphan sub-DAGs, false losses on committed writes). + +Two rules the seam guarantees to every consumer, stated once here: + +1. **`PreconditionFailed` is a value observation, not a value fact.** Per the granularity contract on `ConditionalWriter` (§7), a consumer may assume neither value-level nor revision-level conflict semantics — BdStore's revision emulation can conflict on an unrelated metadata key. Every consumer contract below therefore begins its exit-9 handling with a **re-read**, never with a conclusion. +2. **Mode is invisible at the call site.** Consumers call `beads.ResolveConditionalWriter(store)` (mode is factory-stamped, §6) and get one of: a writer (CAS active), `nil` + once-latched diagnostic (auto degraded → take the byte-identical legacy branch), or a typed refusal error (require ∧ incapable → fail closed). No consumer ever inspects the flag. + +### 9.1 C6 — drain reservation: the three-outcome self-win contract + +Today's `reserveDrainMember` (internal/dispatch/drain.go:1223–1246) is a read-then-write with **three outcomes**, and the CAS port must preserve all three — drains are level-triggered and re-entered, so "already mine" is a normal, frequent state: + +| current owner of `gc.exclusive_drain_reservation` | today (legacy) | must remain | +|---|---|---| +| `""` | `SetMetadata(member, key, control.ID)` — claim | claim | +| `== control.ID` | `return nil` — idempotent re-entry | success | +| `== other` | `drainReservationError` → skip member | skip | + +The naive port — "`CompareAndSetMetadataKey(memberID, key, "", control.ID)`; exit 9 = another drain won → skip" — collapses the middle row into a loss. A re-entered drain would then skip a member **it owns**, no other drain can ever claim it (owner ≠ their ID), and release only covers manifest rows we processed: a permanently stranded, undrainable member that reads as contention. The contract: + +```go +// reserveDrainMemberCAS claims exclusive drain access via value-CAS on the +// member's owning store. Contract: PreconditionFailed is never a loss verdict +// by itself — the caller re-reads and applies the three-outcome table. +func reserveDrainMemberCAS(memberStore beads.Store, cw beads.ConditionalWriter, control, member beads.Bead) error { + ok, err := cw.CompareAndSetMetadataKey(member.ID, beadmeta.ExclusiveDrainReservationMetadataKey, "", control.ID) + if ok { + return nil // claimed + } + var pf *beads.PreconditionFailedError + if err != nil && !errors.As(err, &pf) { + return fmt.Errorf("%s: reserving drain member %s: %w", control.ID, member.ID, err) // transport/exhaustion: surface, retry next tick + } + // Exit-9 (or ok=false): observation, not verdict. Re-read and decide. + current, err := memberStore.Get(member.ID) + if err != nil { ... } + switch owner := strings.TrimSpace(current.Metadata[beadmeta.ExclusiveDrainReservationMetadataKey]); { + case owner == control.ID: + return nil // SELF-WIN: idempotent re-entry, or our own committed-but-unacknowledged write (§9.3) + case owner == "": + // Spurious conflict (BdStore cross-key revision interference, or a raced + // release). Re-issue the CAS once; a second spurious failure surfaces as + // a transient error and the level-triggered pass retries next tick. + return retryReserveOnce(memberStore, cw, control, member) + default: + return drainReservationError{ControlID: control.ID, MemberID: member.ID, Owner: owner} + } +} +``` + +Notes that make this buildable: + +- **Store routing is unchanged.** The CAS runs on `drainMemberOwningStore(store, member.ID, opts)` — members may live in the work-class store, not the graph store — and capability is asserted on *that* resolved store, per member. A mixed topology (graph store capable, one rig's bd store not) degrades only the members it owns. +- **Release is symmetric and rides the same PR.** `releaseDrainReservations` becomes `CompareAndSetMetadataKey(memberID, key, control.ID, "")`: losing that CAS means the member was already re-claimed by a successor drain, which is precisely the case where clearing it would be a clobber — the loss is the correct outcome and is logged at debug, never retried. +- **Tests (MemStore, in-process, merge gate of stage 3):** (a) plain contention — two controls race, exactly one owns, loser skips; (b) **re-entry** — reserve, re-enter the same drain, assert `nil` not skip; (c) **ambiguous-retry** — fake runner commits the write then returns `i/o timeout`; assert the retry self-wins (§9.3); (d) spurious-conflict — inject `PreconditionFailed` with the key still empty, assert one bounded re-issue. + +### 9.2 C4 — Attach epoch fence: CAS-last, losers feed the existing partial-attach recovery + +`molecule.Attach` (internal/molecule/molecule.go:251–311) brackets two unfenced multi-write operations — `Instantiate` (the sub-DAG) and `DepAdd` (the blocking edge) — between an early epoch *check* (line 260–268, `ErrEpochConflict`) and a late epoch *increment* (line 308–311, plain `SetMetadata`). CAS on one key cannot make that whole span atomic; the design decision is **which side of the span the authoritative fence sits on**, and the answer is pinned: **CAS-last**. + +- **CAS-first is rejected** because a crash after the CAS but before `Instantiate` burns the epoch with no idempotency record: the retry re-reads the advanced epoch, `findExistingAttach` finds nothing (nothing was created), and the attempt-numbering that `syncControlEpochToAttempt` (internal/dispatch/control.go:304) exists to repair goes permanently skewed. +- **CAS-last** means both racers may fully materialize sub-DAGs before one loses — so the loser's cleanup must be specified, and it is: the loser is wired into the **existing** partial-attach recovery machinery rather than a new mechanism. + +The port: + +1. Keep the early cheap epoch check exactly as-is (fast-fail for the common already-advanced case; byte-identical when `ExpectedEpoch == 0`). +2. Keep `findExistingAttach` running **before** the fence (molecule.go:251) — this ordering is load-bearing for the ambiguity contract (§9.3) and is now documented on `AttachOptions.ExpectedEpoch` as a contract, not an implementation accident. +3. Replace the final `SetMetadata` increment with the fence: + +```go +ok, err := cw.CompareAndSetMetadataKey(attachBeadID, beadmeta.ControlEpochMetadataKey, + strconv.Itoa(opts.ExpectedEpoch), strconv.Itoa(opts.ExpectedEpoch+1)) +``` + +4. **Loser path** (`ok == false` / `PreconditionFailed`, after side effects exist): Attach itself neutralizes what it just created, because only Attach knows the IDs — (a) stamp the just-created sub-DAG via the existing `markFailed` walk (molecule.go:1291, sets `molecule_failed=true` on all created beads), which makes the orphan root discoverable by `failedAttemptAttachRootID`'s query (control.go:569: idempotency key + root bead + `molecule_failed:true`) and skippable by `findExistingAttach`'s existing `molecule_failed` guard (molecule.go:343); (b) `DepRemove(attachBeadID, result.RootID)` to detach the blocking edge so the attach bead cannot wedge on an orphan root no processor will ever run; (c) return `ErrEpochConflict` wrapped in the dispatch layer's `partialAttemptAttachError` shape so `markControllerSpawnError` (control.go:321) classifies it hard-for-this-attempt rather than transient-retry. The next level-triggered pass re-enters, `findExistingAttach` returns the **winner's** sub-DAG, and the system converges with zero new recovery machinery. +5. `syncControlEpochToAttempt` collapses onto the same helper: `CompareAndSetMetadataKey(control.ID, key, itoa(current), itoa(attemptNum))`. Its exit-9 is benign by construction — another processor advanced the epoch first — so the contract is: re-read; if `current >= attemptNum`, return nil; else re-issue once. + +Capability is asserted on the **graph-class store** that actually holds `gc.control_epoch` — on the deployed topology that is the sqlite graph store, which is why §10's sqlite `CompareAndSetMetadataKey` is a blocking deliverable of this same PR, not a follow-up. + +**Test (integration, stage-3 merge gate):** two concurrent `Attach` calls sharing an idempotency key and `ExpectedEpoch`; assert exactly one sub-DAG survives live, the loser's root carries `molecule_failed=true` with no inbound blocking edge from the attach bead, and a third re-entrant call returns the winner via `findExistingAttach`. + +### 9.3 The ambiguity contract: committed-but-unacknowledged writes + +`isBdAmbiguousWriteError` (internal/beads/bdstore.go:1884) already names the class — `i/o timeout`, `broken pipe`, `connection reset`, `deadline exceeded` — where **the write may have committed** even though the caller saw an error. For CAS this is lethal in a specific way: the retry's `PreconditionFailed` may be caused by *our own first attempt*. The contract, documented on `ConditionalWriter` and enforced per consumer: + +| written value | can the writer recognize its own committed write on re-read? | on ambiguous error, concluding "lost" is… | +|---|---|---| +| **writer-identifying** (C6 reservation = `control.ID`; C6 release; C2 mutations attributed by revision) | yes — re-read and compare | **forbidden** without a self-win check first | +| **non-identifying** (C4 epoch: `expected+1` is indistinguishable from a competitor's increment) | no | **tolerated only because** `findExistingAttach` idempotency runs before the fence and converges the retry onto whichever sub-DAG won | + +Mechanically: CAS calls are **never** routed through the `isBdTransientWriteError` blind retry loop (it contains the ambiguous class and would replay a stale `--if-revision N`); the dedicated CAS policy (§7) surfaces exit 9 immediately and the *consumer* re-reads and re-decides per its table above. The C4 tolerance is written on the seam as a conditional: if anyone ever reorders `findExistingAttach` after the epoch check, the tolerance is void and the ambiguity contract is violated — the comment says so at both sites. + +**Test (fake `CommandRunner`, unit):** inject an ambiguous transport error *after* committing the write; assert C6 re-entry self-wins (member stays reserved by us, no skip) and C4 converges via the idempotency path with exactly one live sub-DAG. + +### 9.4 The fleet-scoped mixed-writer invariant + +CAS provides mutual exclusion **only among CAS writers**. A single legacy writer to the same ledger — a second gc node at `off`, an older binary, an Auto-degraded node with a stale bd — still blind-`SetMetadata`s over CAS-won values, and the CAS node's doctor reads ACTIVE while the race it paid for is open. The invariant, stated verbatim in the design doc and the runbook: + +> CAS mutual exclusion on a ledger holds only when **every writer to that ledger is CAS-active**, or **exactly one writer exists**. + +Within one process this is guaranteed by construction: the mode is process-latched (§5) and factory-stamped (§6), so one process cannot mix write disciplines on one store. Across processes it cannot be guaranteed, only surfaced: `gc doctor` warns when the resolved mode is `auto` but any store's verdict is DEGRADED under a declared multi-writer topology, and the `beads.conditional_writes.degraded` event (§11) makes the degraded node visible fleet-wide rather than only to whoever runs doctor on it. Until the sqlite `ConditionalWriter` integration test soaks against the deployed store shape, the runbook forbids `require` on the deployed topology (§10). + +### 9.5 C2 — API optimistic concurrency: ETag / If-Match / 412 + +C2 is sequenced last because it is the only consumer that needs the beads **library** bump (`go.mod`), and that bump has an unavoidable wire consequence: `beads.Bead` is embedded directly in response types (`BeadGraphResponse` at internal/api/handler_beads.go:374, and every other bead-bearing response), so the moment the library version carrying `Revision int64` lands, **`revision` appears in the OpenAPI schema whether or not the flag is on**. `TestOpenAPISpecInSync` will red on any PR that bumps go.mod without regenerating. Therefore the wire change is *not* flag-gated and *cannot* be: the go.mod bump PR carries, atomically, the genspec regen, all three tracked OpenAPI copies, the dashboard TS regen, and `make dashboard-check` — and the C2 handler work plus the status-wire `beads_conditional_writes` struct (§11) ride that same PR, because the spec-regen tax is already paid. + +The HTTP surface, kept deliberately boring (standard RFC 9110 conditional requests): + +- **ETag out:** every bead-returning GET sets `ETag: ""` (strong, quoted decimal of `Bead.Revision`). The body's `revision` field and the header always agree; clients may use either. +- **If-Match in:** mutating bead endpoints (update, close, delete, assign) accept a typed Huma header param — `IfMatch string \`header:"If-Match"\`` — parsed as exactly one strong ETag. Weak validators (`W/"..."`), lists, and `*` are rejected with the standard Huma 422 validation error; there is no partial support to misread. + +| client sends | flag/store verdict | behavior | +|---|---|---| +| no `If-Match` | any | legacy unconditional semantics, byte-identical to today — clients migrate incrementally | +| `If-Match: "42"` | active (mode ∈ {auto, require} ∧ store capable) | store-level `*IfMatch` write; success → 2xx with fresh `ETag`; revision mismatch → **HTTP 412** with the registered `apierr` `precondition_failed` body carrying `expected_revision` and `current_revision` (mapped from `PreconditionFailedError` — the same forensics the log line gets) | +| `If-Match: "42"` | inactive (mode off, or store incapable) | **HTTP 501** with registered `apierr` `conditional_writes_unsupported`, naming the mode/verdict and origin. Never 2xx: silently executing an unconditional write under a presented precondition is the API-shaped silent fallback this design forbids. 501 (not 412) so client retry loops terminate — a 412 would send well-behaved clients into re-GET-and-retry against a server that can never honor the condition | + +Handler sketch (one shared helper, not per-endpoint logic): + +```go +// conditionalBeadWrite resolves the CAS verdict for the request and either +// runs the conditional write, the legacy write, or refuses — exactly one path. +func conditionalBeadWrite(store beads.Store, ifMatch string, + legacy func() error, + conditional func(cw beads.ConditionalWriter, expected int64) error) error { + + if ifMatch == "" { + return legacy() + } + expected, err := parseStrongETag(ifMatch) // 422 on weak/list/* + if err != nil { return err } + cw, diag, err := beads.ResolveConditionalWriter(store) + if err != nil || cw == nil { // require∧incapable, or off, or auto∧incapable + return apierr.ConditionalWritesUnsupported(diag) // 501, typed, never silent + } + return conditional(cw, expected) // PreconditionFailedError → apierr.PreconditionFailed → 412 +} +``` + +Note the asymmetry with C4/C6: the API consumer performs **no re-read and no self-win logic**. The HTTP client owns the retry loop (re-GET, rebase, resend with the new ETag) — that is the entire point of surfacing 412 with `current_revision` — so the ambiguity contract's writer-identifying row is satisfied by the client, not the server. The server's only obligations are: never convert a presented precondition into an unconditional write, and never return a stale ETag (the CachingStore evict-on-`PreconditionFailed` discipline from §7 is what makes the second obligation hold; its regression test is a stage-2 merge gate, before any C2 handler exists). + +**Tests (stage 4):** handler-level table tests for all four rows above; a 412 round-trip asserting `expected_revision`/`current_revision` in the body and that a follow-up GET's ETag equals `current_revision`; `TestOpenAPISpecInSync` and `make dashboard-check` green in the same PR as the go.mod bump. + +## 10. The sqlite graph store (deployed reality) + +Everything in this design that talks about the epoch fence and the drain reservation is, on the fleet that motivated the work, talking about **one SQLite file**. The deployed controller runs the `deploy/sqlite-b36-probe-attribution` lineage, where `[beads] graph_store = "sqlite"` routes the graph coordination class to an embedded pure-Go SQLite store (`modernc.org/sqlite`, CGO_ENABLED=0) at `/.gc/beads.sqlite`, minting `gcg-` bead IDs. That store — not Dolt, not bd — holds `beadmeta.ControlEpochMetadataKey` (`gc.control_epoch`) and `beadmeta.ExclusiveDrainReservationMetadataKey` (`gc.exclusive_drain_reservation`, the drain "reserved_by" key). Two facts follow, and they set this section's scope: + +1. **`origin/main` has no `SQLiteStore` at all** (verified: `internal/beads/` on this lineage contains `bdstore*`, `caching_store*`, `memstore`, `doltlite_read_store` — no sqlite files; `resolveClassStore` in `cmd/gc/class_store.go` is an identity seam that returns the work store for every class). The C4/C6 code lands on main, but the fence it guards executes on the deploy lineage. +2. Without a sqlite `ConditionalWriter`, the flag is dead on arrival exactly where it matters: under `auto` the graph store fails the interface assert → permanent DEGRADED → zero correctness gain on the deployed fleet while a dev laptop's doctor shows ACTIVE; under `require`, every `molecule.Attach` epoch advance and every exclusive-drain reservation returns a typed refusal → the hottest control path stalls fleet-wide. + +The sqlite `CompareAndSetMetadataKey` plus an integration test against the deployed store shape is therefore a **blocking deliverable of the C4/C6 PR** — a merge-gate checklist item, not a risks footnote. + +### 10.1 Write-path facts that constrain the implementation + +The deployed store's shape (read from `deploy/sqlite-b36-probe-attribution:internal/beads/sqlite_store.go`) dictates the CAS design: + +- **Dual representation.** `bead_json` on the `beads` table is canonical for reads (`getTx` does `SELECT bead_json FROM beads WHERE id=?`); the `metadata(bead_id, meta_key, meta_value)` table with `PRIMARY KEY(bead_id, meta_key)` is a query index (`idx_metadata_key_value`). Every write (`Update` → `upsertBeadTx`) rewrites both. A CAS that updates only the index row leaves reads serving the stale value from `bead_json`; a CAS that updates only `bead_json` breaks `ListByMetadata`. **Both representations move in one transaction or the store is corrupt.** +- **Concurrency model.** One write connection (`MaxOpenConns=1`) serializes in-process writers; WAL mode + `busy_timeout=5000` + the application-level `retryOnBusy` (3 × 150 ms) handle cross-process contention — and cross-process contention is real: the controller and every short-lived `gc` CLI invocation on the host (`gc ready`, order dispatch, sweeps) open the same file, sharing an in-process handle via `graphStoreHandleCache`. +- **Snapshot-upgrade safety.** Mutations use the `ReleaseIfCurrent` template: deferred `BeginTx` → `getTx` → mutate in Go → `upsertBeadTx` → `Commit`, inside `retryOnBusy`. Under WAL, a deferred transaction that read a snapshot and then writes fails with `SQLITE_BUSY_SNAPSHOT` if **any** other commit intervened; `isSQLiteBusy` matches that error string (`"database is locked"`), so `retryOnBusy` re-runs the whole closure against a fresh read. This is what makes read-compare-write inside one deferred tx sound — but we do not lean on it alone: the CAS guard below lives in the WHERE clause of the committing statement, so the verdict is evaluated by SQLite at write time, never by Go against a possibly-stale snapshot. +- **Local commits are unambiguous.** Unlike the bd subprocess transport, a local WAL `COMMIT` either returns nil or the transaction rolled back. The ambiguous-outcome self-win contract (consumer-contracts section) is a bd-transport artifact; consumers keep it because the interface is store-agnostic, but the sqlite implementation never manufactures that state. + +### 10.2 `CompareAndSetMetadataKey`: the conditional UPDATE + +New file `internal/beads/sqlite_store_conditional.go` — **new-file-only**, per the upstream-alignment rules, so the identical commit applies to both the deploy lineage (where `SQLiteStore` exists today) and main (when the store is promoted). + +```go +var _ ConditionalWriter = (*SQLiteStore)(nil) + +// CompareAndSetMetadataKey sets key to next iff its current value equals +// expected, treating an absent metadata row as "". This is VALUE-CAS: +// concurrent writes to OTHER metadata keys on the same bead do not fail the +// guard (contrast the BdStore revision-emulation loop, which they do). +// The compare is the WHERE clause of the committing UPDATE, so the verdict +// and the mutation are one atomic statement — a stale in-Go compare is +// structurally impossible. +func (s *SQLiteStore) CompareAndSetMetadataKey(id, key, expected, next string) (bool, error) { + var won bool + err := retryOnBusy(func() error { + won = false + ctx := context.Background() + tx, err := s.db.BeginTx(ctx, nil) // single write conn, MaxOpenConns=1 + if err != nil { + return fmt.Errorf("sqlite cas %q: begin tx: %w", id, err) + } + defer tx.Rollback() //nolint:errcheck + b, err := s.getTx(ctx, tx, id) // ErrNotFound mapping preserved + if err != nil { + return err + } + if b.Metadata == nil { + b.Metadata = make(map[string]string, 1) + } + b.Metadata[key] = next + b.UpdatedAt = time.Now() + payload, err := json.Marshal(b) + if err != nil { + return fmt.Errorf("sqlite cas %q: marshal: %w", id, err) + } + // THE guard. COALESCE folds "no row" to "", so expected=="" means + // "claim if unset" — the exact drain-reservation shape. + res, err := tx.ExecContext(ctx, ` + UPDATE beads + SET bead_json = ?, updated_at = ?, revision = revision + 1 + WHERE id = ? + AND COALESCE((SELECT meta_value FROM metadata + WHERE bead_id = ? AND meta_key = ?), '') = ?`, + string(payload), b.UpdatedAt.UnixNano(), id, id, key, expected) + if err != nil { + return fmt.Errorf("sqlite cas %q: %w", id, err) + } + if n, _ := res.RowsAffected(); n == 0 { + return nil // lost: deferred Rollback, nothing written, won stays false + } + // Guard passed: keep the metadata index in lockstep with bead_json. + if _, err := tx.ExecContext(ctx, ` + INSERT INTO metadata(bead_id, meta_key, meta_value) VALUES(?, ?, ?) + ON CONFLICT(bead_id, meta_key) DO UPDATE SET meta_value = excluded.meta_value`, + id, key, next); err != nil { + return fmt.Errorf("sqlite cas %q: index row: %w", id, err) + } + if err := tx.Commit(); err != nil { + return err + } + won = true + return nil + }) + return won, err +} +``` + +Notes that survive review questions: + +- **Why the marshalled `bead_json` can't clobber a concurrent sibling write:** if any other commit lands between `getTx` and the UPDATE, the deferred tx's write upgrade fails `SQLITE_BUSY_SNAPSHOT` and `retryOnBusy` re-runs the closure with a fresh read. The guard's WHERE clause is defense-in-depth on top of that, not the only line. +- **Loss returns `(false, nil)`** — the interface's value-CAS verdict. The caller re-reads and re-decides per the consumer contracts (self-win check for writer-identifying values; skip vs converge). `ErrNotFound` propagates from `getTx`; `reserveDrainMember` already treats it as a no-op, which also covers the retention sweeper deleting a terminal bead between read and CAS. +- **`Delete` + retention interplay:** the 4-hour terminal-record sweeper can remove a bead under a contender; the guard then hits zero rows via the `id` predicate and the earlier read's `ErrNotFound` — never a false win. + +### 10.3 The revision column and the rest of the interface + +Capability is interface satisfaction on the resolved store — all-or-nothing — and the conformance suite (test-seams section) runs sqlite in **unit CI**. So the blocking PR ships the full `ConditionalWriter`, not just the metadata method; the revision-keyed trio is the same conditional-statement shape three more times: + +```go +func (s *SQLiteStore) UpdateIssueIfMatch(id string, expected int64, opts UpdateOpts) error +func (s *SQLiteStore) CloseIssueIfMatch(id string, expected int64) error +func (s *SQLiteStore) DeleteIssueIfMatch(id string, expected int64) error +// guard: WHERE id = ? AND revision = ?; RowsAffected == 0 → re-read revision +// in-tx → PreconditionFailedError{Expected: expected, Current: cur} (or ErrNotFound). +``` + +- **Schema migration, idempotent on the live deployed file:** `applySchema` (already run on every open) gains a `pragma table_info(beads)` column check and, when absent, `ALTER TABLE beads ADD COLUMN revision INTEGER NOT NULL DEFAULT 0`. `ADD COLUMN` is schema-only (no row rewrite) — safe against a WAL file other processes hold open. Existing rows start at 0; `upsertBeadTx` bumps via the `ON CONFLICT` arm (`revision = beads.revision + 1`), the insert arm starts at 1; reads stamp `Bead.Revision` from the column (column authoritative, `bead_json` never carries it) once the Stage-4 library bump adds the field — until then the conformance suite reads `Current` off `PreconditionFailedError`, which is the revision oracle the interface already guarantees. +- **Mixed-binary ABA — the sharp edge that justifies the settled scoping.** During a deploy window, an *old* gc binary writing the same file mutates beads through the pre-revision `upsertBeadTx`, which never names the column — mutations that **don't bump revision**. A revision-keyed CAS by the new controller can then pass its guard despite an intervening write: classic ABA. `CompareAndSetMetadataKey` is immune (it compares the value itself), **and C4/C6 consume only `CompareAndSetMetadataKey`** — which is exactly why the value-CAS method is the blocking correctness core and the revision trio is trustworthy on this file only once every gc binary on the host runs a revision-bumping build. The runbook carries this rule verbatim. + +### 10.4 Wrapper transparency on the resolved path + +The controller never holds a bare `*SQLiteStore`. The resolved graph store is wrapped, and each wrapper interacts differently with the type assert (the `class_store.go` lesson: optional interfaces are NOT promoted through hand-rolled delegation): + +| Wrapper (deploy lineage) | Shape | ConditionalWriter status | +|---|---|---| +| `noCloseGraphStore{*beads.SQLiteStore}` | embeds the concrete pointer | promoted automatically; add `var _` assert anyway | +| `lazyGraphStore` (self-healing open) | hand-rolled per-method delegation | **must add explicit forwarding methods** or the resolved store reads incapable forever | +| `beadPolicyStore` / `beadPolicyGraphStore` (main) | hand-rolled delegation | same — explicit forwarding (this wrapper already dropped `ListGraphOnlyHandle` once; the regression class is proven) | +| `CachingStore` | covered in the machinery section (forward + evict) | — | + +Forwarding rule for `lazyGraphStore`: while unhealed, `CompareAndSetMetadataKey` returns the **open error** — never `ErrConditionalWriteUnsupported`. A transient open failure must fail loud (matching the store's documented fail-loud reads/writes), not latch the store incapable and silently degrade `auto` to the legacy path. A wrapper-transparency test resolves the graph store exactly as the controller registers it (`graph_store="sqlite"` → lazy wrapper → shared-handle cache → policy wrap) and asserts the **resolved** value satisfies `ConditionalWriter`. + +### 10.5 The blocking integration test + +`test/integration/graph_store_sqlite_cas_test.go`, `//go:build integration`, staged where `SQLiteStore` exists — the deploy lineage today (`test/integration/graph_store_sqlite_convergence_test.go` and `test/agents/graph-store-sqlite-worker.sh` on that branch are the templates for topology and the second-process harness). Legs, each a named subtest: + +1. **Resolved-path capability** — temp city with `[beads] graph_store = "sqlite"`; resolve through the controller's registration path; assert `ConditionalWriter` satisfaction on the resolved store; assert an unhealed lazy store returns the open error, not `ErrConditionalWriteUnsupported`. +2. **Epoch-fence exclusion (in-process)** — seed a `gcg-` control bead with `gc.control_epoch = "3"` plus sibling metadata; 8 goroutines CAS `"3" → "4"`; exactly one `true`; final value `"4"`; sibling keys byte-identical; revision advanced exactly once for the CAS. +3. **Drain-reservation exclusion (cross-process)** — the deployed contention is controller-vs-CLI on one `.gc/beads.sqlite`: a second OS process hammers `CompareAndSetMetadataKey(member, gc.exclusive_drain_reservation, "", )` across M members while the test process competes with its own ID; assert exactly one owner per member, losers observed `false`, no `SQLITE_BUSY` leaks through `retryOnBusy`, and index-vs-`bead_json` agreement on re-read (`Get` and `ListByMetadata` return the same owner). +4. **Deployed-file migration** — open a fixture `beads.sqlite` created with the pre-revision schema verbatim and populated rows carrying `gc.control_epoch`; assert the open migrates idempotently (open twice), CAS works against pre-existing rows, revisions start at 0. +5. **Busy/snapshot retry** — pin the WAL write lock past `busy_timeout` from a helper connection; assert the CAS converges to a correct verdict after retry, never a false win. + +The store-agnostic conformance suite additionally runs `SQLiteStore` in **unit** CI (`t.TempDir()`, pure-Go driver — no build-tag excuse; only the multi-process leg needs the integration tag). + +**Gate:** the C4/C6 PR's merge checklist names this file green *on the lineage the fleet deploys from*. main's identity `resolveClassStore` means main-only green proves nothing about the deployed fence. + +### 10.6 Interim rules: doctor rendering and the runbook prohibition + +Until the deliverable lands and soaks, the four-cell matrix instantiates on the deployed topology as: + +| `conditional_writes` | Deployed gc (no sqlite ConditionalWriter) | After the deliverable | +|---|---|---| +| `off` | legacy, byte-identical to today | legacy | +| `auto` | graph class **DEGRADED**: interface assert fails, once-per-store `beads.conditional_writes.degraded` event, legacy writes — zero gain on the fence keys | CAS on `gc.control_epoch` / `gc.exclusive_drain_reservation` | +| `require` | typed refusal on **every** epoch advance and drain reservation → controller-wide stall; doctor ERROR, nonzero exit | CAS | + +`gc doctor` renders the graph-class store's verdict specifically (`store=graph kind=sqlite capable=false reason="SQLiteStore predates ConditionalWriter"`), per the observability section's per-store array — never folded into an aggregate boolean. + +Runbook text (verbatim, shipped with the C4/C6 PR): + +```markdown +### conditional_writes where [beads] graph_store = "sqlite" — interim rules + +- `require` is FORBIDDEN while the running gc predates the sqlite + ConditionalWriter. Every molecule.Attach epoch advance and every + exclusive-drain reservation would refuse → controller-wide stall. + gc doctor renders this ERROR (nonzero exit) before you deploy it. Believe it. +- `auto` is safe but a no-op for graph-class writes: DEGRADED, typed event, + today's TOCTOU behavior retained. You gain nothing on the fence keys. +- Lift the prohibition only when ALL hold: + (1) sqlite ConditionalWriter + the deployed-topology integration test are + merged on the lineage the fleet deploys (deploy/sqlite-b36-probe-attribution + today — NOT origin/main); + (2) conformance suite green including sqlite; + (3) >= 1 week soak on maintainer-city at `auto` with zero degraded events + from the graph store and doctor showing graph=capable. +- Revision-keyed CAS (UpdateIssueIfMatch et al.) on this file is trustworthy + only when every gc binary on the host runs a revision-bumping build — + an old binary's writes do not bump the column (ABA). Value-CAS + (CompareAndSetMetadataKey) is immune; C4/C6 use only value-CAS. +- Mixed writers on one .gc/beads.sqlite are the controller PLUS every + short-lived gc CLI process on the host. CAS mutual exclusion holds only + when every writer is CAS-active or exactly one writer exists. Flip modes + via city.toml + controller restart only; a per-process env override + (GC_BEADS_CONDITIONAL_WRITES) splits the writer set on a single file — + exactly the mixed-writer topology doctor warns about. +``` + +## 11. Lifecycle and flag-debt enforcement + +The registry's anti-rot teeth are worthless if they fire as wall-clock time bombs. This repo's merge pipeline is driven by an autonomous fleet whose quality gates treat any red as a stall (prior art: the zero-merges RCA, the tracked trivyignore cliff of 2026-08-07). A check that reds `main` with zero diff — same commit passing Tuesday, failing Wednesday — trains everyone to neuter it the first time it fires, breaks bisect, and wedges every open PR at once. So lifecycle enforcement is split along one bright line: + +| Tooth | Trigger | Where it runs | Blocking? | +|---|---|---|---| +| Registry structural validation (Category, ConfigPath reflection, Default parity, dual Owner, per-category field rules, EnvOverride ∈ LeakVectorVars) | any commit | `registry_test.go`, PR CI | **yes** — deterministic per commit | +| Graduation stage 1 (default must leave Off) | deps.env `BD_VERSION` crosses the floor | `scripts/bd_version_pin_test.go` family, PR CI | **yes** — fires only in the diff that moves the anchor | +| Graduation stage 2 (flag must be deleted) | deps.env `BD_PREV_VERSION` crosses the floor | same test, PR CI | **yes** — fires only in the diff that moves the anchor | +| Wall-clock `Expires` past due | calendar | nightly radar → bead against Owner + doctor WARN | **no** — except when `registry.go` is in the PR diff | +| Tombstone past `RemovedIn`+1 | version anchor comparison | nightly radar → bead | **no** | +| Owner-bead liveness | bead closed/purged | nightly radar → bead against Owner.GitHub | **no** | + +**Normative rule: no merge-blocking check in this subsystem may compare against `time.Now()`.** Every hard CI failure must be a pure function of repo state at the commit — version anchors in `deps.env`, fields in `registry.go`, code in the tree. Wall-clock staleness is real debt, but it is the radar's job, not Check's. + +### 11.1 Lifecycle fields on the Spec + +The lifecycle-bearing subset of `Spec` (full shape in §4): + +```go +type Owner struct { + Bead string // "ga-xxxxx" — work tracking; the radar files/updates against it + GitHub string // "@handle" or "@org/team" — the named human gate; both required non-empty +} + +type Spec struct { + // ... identity/config/env fields (§4) ... + Owner Owner + Expires string // "2027-01-15"; radar-only. Mandatory for rollout/migration, forbidden for killswitch. + VersionAnchor string // deps.env key naming the capability floor, e.g. "BD_CONDITIONAL_WRITES_MIN_VERSION". + // Mandatory for rollout/migration, forbidden for killswitch. + GraduatedIn string // BD_VERSION value at the Off→Auto flip; "" until stage 1 fires. Set in the flip PR. + FlipDueBy string // bounded deferral: a BD_VERSION literal. Set only by a bump PR that trips stage 1. +} +``` + +Owner beads get closed and bulk-purged in this project (the cache-reconcile incident), so `Owner.Bead` alone is decorative — a fired trigger naming a tombstoned bead reaches nobody. `Owner.GitHub` plus the CODEOWNERS line is the mechanical human gate: + +``` +# .github/CODEOWNERS +/internal/rollout/registry.go @gastownhall/gascity-admins +``` + +Every Spec addition, `Expires` extension, `FlipDueBy` deferral, and category claim now requires a named-human review. In an agent-authored repo this is the only real tooth for semantic judgments ("is this genuinely a killswitch?"); the design says so plainly rather than pretending a test can read a justification string. + +### 11.2 Two-stage version-anchored graduation: one plain Go test + +No predicate DSL, no `RemovalTrigger` mini-language. Graduation is one ~20-line test in the `TestBDVersionPins` family (`scripts/bd_version_pin_test.go` — it already owns `readDotenv`/`repoRoot` and keeps every bd anchor in lockstep). The CAS floor is a Go constant in `internal/beads` mirroring the `bdReadyProjectionMinVersion = "1.0.5"` precedent, tied to a new `deps.env` key `BD_CONDITIONAL_WRITES_MIN_VERSION` under the existing lockstep assertions: + +```go +func TestConditionalWritesGraduation(t *testing.T) { + env := readDotenv(t, filepath.Join(repoRoot(t), "deps.env")) + floor := env["BD_CONDITIONAL_WRITES_MIN_VERSION"] // lockstep with beads.bdConditionalWritesMinVersion + spec := rollout.SpecByKey(t, "beads.conditional_writes") + + // Stage 1: the installable default bd can CAS — the builtin default must leave Off. + if deps.CompareVersions(env["BD_VERSION"], floor) >= 0 && spec.Default == rollout.Off { + if spec.FlipDueBy == "" || deps.CompareVersions(env["BD_VERSION"], spec.FlipDueBy) > 0 { + t.Fatalf("bd %s supports --if-revision: flip beads.conditional_writes default Off→Auto and set GraduatedIn, "+ + "or set FlipDueBy=%s in this PR (owner %s / %s)", + env["BD_VERSION"], env["BD_VERSION"], spec.Owner.Bead, spec.Owner.GitHub) + } + } + + // Stage 2: the minimum-supported bd can CAS — the flag itself is now debt; delete it. + if deps.CompareVersions(env["BD_PREV_VERSION"], floor) >= 0 { + t.Fatalf("min-supported bd %s supports --if-revision: DELETE the flag — Spec, Flags accessor, ForTest option, "+ + "BeadsConfig.ConditionalWrites + mergeFragment branch, legacy read-then-write branches, this test — "+ + "and mint the RetiredKeys tombstone (owner %s / %s)", + env["BD_PREV_VERSION"], spec.Owner.Bead, spec.Owner.GitHub) + } +} +``` + +Why two stages, and why these anchors: `BD_VERSION` (the installable default) moves fast; `BD_PREV_VERSION` (the min-supported contract-matrix floor) historically barely moves — it sits at v1.0.4 today, still below the 1.0.5 ready-projection floor introduced a full bd generation ago. A single trigger keyed to the floor plausibly never fires; a single trigger keyed to `BD_VERSION` demands deletion while old bd is still supported. Stage 1 forces the *default flip* the moment the anchor that moves crosses; stage 2 forces *deletion* — the terminal state — the moment the slow anchor crosses. "Default flipped, flag and dual code paths in tree forever" is no longer a green state. + +Both stages are deterministic per commit: they only change verdict when someone edits `deps.env`, i.e., inside the PR that makes graduation possible, where a red is actionable by the person holding the pen. + +**The `FlipDueBy` grace marker.** A version bump is often driven by something else entirely — a bd CVE fix — and forcing a same-PR semantic flip on the epoch-fence path (a change our own risk register says needs soak) would make the bump author choose between reverting a security fix and rush-shipping `Require`-adjacent behavior. So stage 1 offers exactly one bounded escape: the bump PR may set `FlipDueBy` to the `BD_VERSION` it is landing. The deferral holds while `BD_VERSION <= FlipDueBy`; the *next* anchor bump exceeds it and the test reds again. Properties: + +- **Diff-visible.** Setting or raising `FlipDueBy` is an edit to `registry.go` — CODEOWNERS-gated, reviewed by a named human as debt. +- **Bounded.** Grace is one anchor bump, not a date. Re-deferral requires another loud registry edit; the radar independently files against the Owner while any `FlipDueBy` is pending. +- **Silent-forever impossible.** There is no state in which the anchor is past the floor, the default is Off, and CI is green without a visible, reviewed deferral in the file. + +`GraduatedIn` is recorded in the Spec by the flip PR (stage 1's demanded edit), so the registry itself carries the fact stage-2 tooling and the radar reason about — no git archaeology. + +### 11.3 The nightly radar: wall-clock staleness, non-blocking + +Wall-clock `Expires` moves **entirely** out of the merge path. A scheduled nightly workflow runs `go run ./scripts/rolloutradar`, which imports `internal/rollout`, walks the registry, and for each finding **files or updates a bead against `Owner.Bead`** (idempotent: one bead per flag per finding class, updated not duplicated) and reds a **non-blocking** status. Findings: + +- `Expires` past due (flag neither graduated, deleted, nor visibly extended). +- `FlipDueBy` set and pending (a deferred stage-1 flip awaiting its dedicated, soaked PR). +- Tombstone past its version window (§11.5). +- `Owner.Bead` closed or purged — the radar re-files a fresh bead and names `Owner.GitHub`, so a fired trigger can never point at a tombstoned bead with nobody attached. + +The same findings feed `gc doctor`'s Rollout Flags section: approaching/announced items render as WARN; radar-surfaced *past-due* items render per the doctor exit contract pinned in §10 (ERROR, nonzero exit). Operators see the debt; the merge pipeline never stalls on it. + +**The one exception:** past-due `Expires` *does* hard-fail PR CI when `internal/rollout/registry.go` itself is in the diff — you must confront the registry's debt to touch the registry. Mechanically: the Check workflow sets `GC_ROLLOUT_EXPIRY_GATE=1` only when the PR's changed-files list includes `registry.go`, and the expiry assertion in `registry_test.go` is gated on that env var. A commit's verdict still never changes without a diff *to this file*, so bisect and unrelated PRs stay green. + +### 11.4 Per-category immortality rules + +Enforced in `registry_test.go`, deterministically: + +| Category | `Expires` | `VersionAnchor` | Legal terminal state | +|---|---|---|---| +| `infra-rollout` | **mandatory** | **mandatory** | deletion (stage 2) | +| `infra-migration` | **mandatory** | **mandatory** | deletion (stage 2) | +| `infra-killswitch` | **forbidden** | **forbidden** | may be long-lived | + +Rollout and migration flags may **never** be immortal: their whole purpose is to stop existing. There is no `Stability` enum and no "Stable" promotion — the escape hatch where a one-line edit simultaneously exempted a flag from expiry and freed cap headroom does not exist, because neither the hatch nor the cap exists (the soft cap is deleted; a count ceiling delivered zero value at N=2 and maximum friction during incidents). A rollout flag that wants to live forever has exactly one path: reclassify as `infra-killswitch` in a CODEOWNERS-reviewed diff that also *deletes* its `Expires` and `VersionAnchor` — a reclassification no reviewer will wave through by accident, because the diff shape is unmistakable. + +Killswitches skip the graduation machinery but not the radar: owner-bead liveness and doctor rendering still apply. + +### 11.5 Version-anchored tombstones + +When a flag is deleted, its TOML key is minted into `RetiredKeys` in `internal/config/undecoded.go`, downgrading the key from fatal-unknown-field to a friendly warning: + +```go +// internal/config/undecoded.go +type RetiredKey struct { + Path string // "beads.conditional_writes" + RemovedIn string // version anchor at removal, e.g. "v1.2.0" + Message string // rendered verbatim in the load warning +} + +var retiredKeys = []RetiredKey{ + { + Path: "beads.conditional_writes", + RemovedIn: "v1.2.0", + Message: "conditional writes graduated to always-on in v1.2.0; delete this line from city.toml", + }, +} +``` + +```toml +# operator's stale city.toml +[beads] +conditional_writes = "auto" +# → warning at load: city.toml: "beads.conditional_writes" was retired in v1.2.0 — +# conditional writes graduated to always-on in v1.2.0; delete this line from city.toml +``` + +Tombstone lifetime is **version-anchored, never wall-clock, never "one release"** — this fleet deploys branches, not tags (maintainer-city runs a `deploy/*` branch), so "one release" has no machine meaning for the binaries actually running. The nightly radar flags a tombstone for deletion once the current version anchor exceeds `RemovedIn` by more than one bump (`RemovedIn+1`), filing a bead to remove the entry. No tombstone comparison ever appears in a merge-blocking test; the time-bomb class is not relocated into `undecoded.go`. The concrete version in the warning text is also better operator UX than "recently": the slow-upgrading operator the tombstone exists for learns exactly which version removed the key. + +### 11.6 ADDING a flag: the four-place checklist + +One PR, four places, each with a test that fails if skipped: + +1. **Config field + pure accessor** on the *owning* section struct (e.g. `BeadsConfig.ConditionalWrites` with jsonschema enum tags, accessor mapping `""`→default). If the section is whole-table LWW in `mergeFragment`, the per-field `IsDefined` preservation branch **and its hand-written merge regression test** (the `daemon.formula_v2` template) land here (§5). Load-time enum validation is registry-driven and comes for free. +2. **The Spec** in `internal/rollout/registry.go`. `registry_test.go` gates, all deterministic: Category in the closed enum; `ConfigPath` reflection-resolves against `config.City`'s toml tags; `EnvOverride` is `""` or a `GC_*` name registered in testenv `LeakVectorVars`; **`Default` equals the typed accessor's value over a zero-value `config.City`** (closing the two-homes drift where doctor renders the registry while the binary behaves per the accessor — a half-landed graduation PR fails here); dual Owner non-empty; per-category `Expires`/`VersionAnchor` rules (§11.4); `SelectsBetween` names both mechanical code paths. +3. **Typed accessor on `Flags`** plus the generated `rollout.With(...)` ForTest option, always as a pair. +4. **DI threading**: factory stamping for store-mediated flags, options-struct fields for consumers, plus the entry-point test asserting a temp-`city.toml` value is observed by a probe (§7). + +Graduation PRs use the same checklist in reverse gear: the stage-1 flip edits the accessor's builtin mapping **and** `Spec.Default` **and** `Spec.GraduatedIn` in one diff — the Default-parity test makes a partial flip unmergeable. + +### 11.7 REMOVING a flag: compile-enforced + +Removal is one PR that deletes, in order: the Spec; the `Flags` accessor and its `With*` ForTest option; the config field and its accessor; the `mergeFragment` preservation branch and merge test; the dead legacy code branches; the graduation test; the `LeakVectorVars` entry. Then it mints the tombstone (§11.5). + +The compiler is the enforcement. Because every production read is a typed method (`flags.BeadsConditionalWrites()`) and every test override is a typed option (`rollout.WithBeadsConditionalWrites(rollout.Require)`), deleting the flag breaks **every** production call site *and every test* at compile time — no string-keyed lookup survives to fail at runtime, no grep-and-pray. The four-cell-matrix tests per consumer (§8) go with it; the legacy path's disappearance means the `off` cell's byte-identical assertion has nothing left to compare against, which is the point. + +What keeps removal *reached* rather than merely *possible*: stage 2 of the graduation test reds the anchor-bump PR until this deletion PR exists, the radar nags the Owner's bead in the interim, and no category besides killswitch has a legal state in which the flag simply stays. A flag with no reachable removal state is rejected at review — CODEOWNERS on `registry.go` — as a disguised permanent toggle. + +## 12. Observability and operability + +The operating principle for this section: **the running daemon is the source of truth; every other surface either renders the daemon's own snapshot or says loudly that it could not.** Observability ships in three deliberate stages so the correctness-critical PRs are never hostage to wire-schema churn. + +| Surface | Stage | Mechanism | What it answers | +|---|---|---|---| +| `gc doctor` — Rollout Flags section | 1 | Registry-rendered, local resolution with mandatory banner | "What would this shell resolve, and is any store incapable?" | +| `beads.conditional_writes.degraded` typed event | registered 2, emitted 3 | Event bus, latched once per store | "Did any store silently fall back to legacy writes, ever?" (push, alertable) | +| Status wire + live-API doctor | 4 | Huma-typed aggregate + per-store array, rides the go.mod-bump regen | "What is the daemon *actually* running, including its latches and notices?" | +| `/v0/config/explain` per-layer origin | deferred | New compose.go provenance plumbing | "Which fragment set this?" — built when someone asks, costed honestly then | + +### 12.1 `gc doctor` (slice 1) + +Doctor gains a **Rollout Flags** section rendered by iterating the registry — a new flag gets its row for free from its `Spec` (Key, Owner, Expires, ConfigPath) plus the resolved `Flags` value and, for capability-gated flags, the per-store verdicts. + +Slice-1 doctor is a separate process resolving from **its own** shell env and PATH. That is not the daemon's view, so the banner is unconditional whenever doctor resolves locally: + +``` +Rollout Flags + ! city not running — values resolved from this shell's env and PATH + and may differ from the daemon + + beads.conditional_writes mode=require origin=config owner=ga-c4cas / @gastownhall/gascity-flags + expires: 2027-01-15 (radar-tracked) + stores: + graph (sqlite) probe=capable latch=unlatched ACTIVE + rig gastown (bd) probe=incapable latch=unlatched FAIL-CLOSED + reason: bd 1.1.0 help lacks --if-revision (all four verbs probed) + effective: FAIL-CLOSED — require set but store "rig gastown" is incapable; + CAS writes to that store will refuse. Fix bd or set conditional_writes=auto|off and restart. +``` + +Rendering rules, all registry-driven: + +- **Probe vs latch are separate columns, always.** `probe` is what capability detection reports now (help-text grep today, version-compare post-tag); `latch` is the runtime exit-13/unknown-flag verdict a live store has accumulated. In local mode `latch` is always `unlatched` (doctor's freshly probed stores have no write history); in live-API mode (12.4) it is the daemon's real latch. Collapsing these two was explicitly rejected — "doctor says capable but writes refuse" incidents are diagnosed by exactly this split. +- **Effective status** per flag: `ACTIVE` / `DEGRADED` (auto ∧ any incapable store) / `FAIL-CLOSED` (require ∧ any incapable store) / `off`, plus `pending restart` (12.1.1). Aggregation is worst-of: `fail_closed > degraded > pending-restart > active > off`. +- The **graph-class store is always rendered as its own row** — until the sqlite `ConditionalWriter` integration test soaks on the deployed topology, this row is the operator's only honest view of whether the epoch fence is real where it matters. +- When mode is `auto`, any store is `DEGRADED`, and the config declares a multi-writer topology, doctor prints the **mixed-writer warning**: CAS mutual exclusion holds only when every writer to a ledger is CAS-active or exactly one writer exists. + +#### 12.1.1 Pending restart in slice 1 + +The daemon's authoritative pending-restart notice (boot-latched value ≠ on-disk config after a reload) lives in `controllerState` and reaches doctor exactly via the live API in stage 4. Slice-1 doctor approximates it from **live state only** (no status files): if a running daemon is found in the process table and its start time predates the mtime of any config layer that defines a registry flag, doctor renders: + +``` + ⚠ pending restart? city.toml modified after daemon start (daemon: Jul 08 14:02, + city.toml: Jul 09 09:15). Process-latched flags keep their boot values until restart. +``` + +This is labeled as an approximation in the output; the exact latched-vs-on-disk comparison arrives with 12.4. + +#### 12.1.2 Exit-code contract (pinned by test) + +| Condition | Rendering | Exit | +|---|---|---| +| `FAIL-CLOSED` on any flag (require ∧ incapable) | ERROR | nonzero | +| Radar-surfaced past-due lifecycle item (expired Spec, stale tombstone) | ERROR | nonzero | +| `DEGRADED` (auto ∧ incapable) | WARNING | 0 | +| Pending restart, env-overrides-config notice | WARNING | 0 | +| Everything else | INFO | 0 | + +Rationale: monitoring pipelines wire doctor into cron health checks. FAIL-CLOSED means writes are being refused *right now* — page-worthy. DEGRADED is today's exact legacy behavior plus a flag that wants attention — a page on every auto/stale-bd host would get the check deleted within a week. `TestDoctorRolloutExitContract` constructs a fixture per cell of this table and asserts both the exit code and the ERROR/WARNING classification, so the contract cannot drift silently. + +### 12.2 The push surface: `beads.conditional_writes.degraded` + +DEGRADED is the state most likely to persist unnoticed for months (a fleet on `auto` with one stale-bd host), so it cannot depend on someone thinking to run doctor. Stage 2 registers, stage 3 emits: + +```go +// internal/events — added to KnownEventTypes; payload registered per the +// typed-events invariant (TestEveryKnownEventTypeHasRegisteredPayload). +const EventBeadsConditionalWritesDegraded = "beads.conditional_writes.degraded" + +type ConditionalWritesDegradedPayload struct { + StoreID string `json:"store_id"` // e.g. "rig/gastown", "graph" + StoreKind string `json:"store_kind"` // bd | native | sqlite-graph | caching + Mode string `json:"mode"` // "auto" (require refuses instead of degrading) + Origin string `json:"origin"` // builtin | config | env — where the mode came from + Reason string `json:"reason"` // "bd 1.1.0 lacks --if-revision (unknown flag)" | "exit 13: conditional-write-unsupported" + BDVersion string `json:"bd_version,omitempty"` +} +``` + +Emission is **latched once per store instance**, guarded by the same mutex as the capability latch: the first capability veto on a store fires the event and the log line; subsequent vetoes are silent (log storms structurally impossible, mirroring `native_store_unavailable`). Because `internal/beads` is Layer 0 and must not import the event bus, the factory injects a callback at store-open: + +```go +// internal/beads — factory wiring, nil-safe. +type OpenOptions struct { + // ... + OnConditionalWritesDegraded func(ConditionalWritesDegradedPayload) // nil ⇒ log-only (bare CLI contexts) +} +``` + +`OpenStoreAtForCity` wires it to the bus wherever a bus exists (controller, API server); short-lived CLI paths without a bus fall back to the structured log alone. The event lands in event history and the dashboard's existing event views with zero new UI work, and is the thing an operator alerts on instead of polling doctor. + +Require-mode refusals do **not** get their own event type: each refusal is a typed error that propagates to the failing operation (a stalled drain is already loud), plus the store-open preflight `BeadsDiagnostic` and the doctor ERROR. What they do get is log discipline (12.3). + +### 12.3 Structured diagnostics and log discipline + +- **Every refusal and degrade line carries `mode` and `origin`.** Break-glass scope is per-process, so two processes of one city can legitimately resolve opposite modes; the first log line must make that visible without correlation work: `conditional_writes refused: store=rig/gastown gate=drain_reservation mode=require origin=env reason="bd 1.1.0 lacks --if-revision"`. +- **Store-open veto** (auto ∧ incapable) emits the factory-style `BeadsDiagnostic{PreflightGate:"conditional_writes", PreflightReason:...}` and one `conditional_writes_unavailable` structured log per store — the `native_store_unavailable` vocabulary operators already grep for. +- **Contention forensics:** every `PreconditionFailedError` carries `Expected` and `Current` revisions, so a genuinely contended key versus the CachingStore stale-revision livelock versus BdStore cross-key revision interference are distinguishable from the error text alone. +- **Startup is where env problems surface, fatally or loudly.** An unparseable `GC_BEADS_CONDITIONAL_WRITES` value fails startup naming the var, the raw value, and the grammar (`off|auto|require` — nothing else). A *valid* env value that contradicts an explicitly-set config value starts up but emits a startup structured log plus a typed event and a retained notice: `conditional_writes=off (env GC_BEADS_CONDITIONAL_WRITES) overriding require (config)`. + +### 12.4 Notice retention + +Resolve's notices are not stderr ephemera. They are retained **on the `Flags` value for the process lifetime** and rendered by every later surface: + +```go +// internal/rollout +type Origin string // "builtin" | "config" | "env" + +type NoticeKind string + +const ( + NoticeEnvOverridesConfig NoticeKind = "env_overrides_config" // valid env contradicts explicit config + NoticePendingRestart NoticeKind = "pending_restart" // on-disk config ≠ boot-latched value (recorded at reload) + NoticeInvalidEnvIgnored NoticeKind = "invalid_env_ignored" // kill-switch flags only; Mode flags fail startup instead +) + +type Notice struct { + Flag string // registry Key + Kind NoticeKind + Origin Origin // origin of the EFFECTIVE value + Detail string // e.g. `require (city.toml) != off (latched at start) — restart to apply` +} + +func (f Flags) Notices() []Notice // immutable copy +``` + +`controllerState` holds the boot-resolved `Flags`, so the notices survive log rotation by construction — the answer to "*why* is the effective mode what it is?" is recoverable from a three-week-old daemon without a restart. The reload path appends `NoticePendingRestart` when on-disk config diverges from a process-latched value; it never mutates existing notices. Boundary test (stage 4, once the wire exists): start a daemon with an env override contradicting a temp `city.toml`, query the status endpoint, assert the `env_overrides_config` notice is present verbatim. + +### 12.5 Status wire and live-API doctor (stage 4) + +The wire type rides the C2/go.mod-bump PR, which already forces genspec, the three tracked OpenAPI copies, and dashboard TS for `Bead.Revision` — the flag field is free cargo on an unavoidable regen. One boolean cannot express a mixed fleet, so the type is an aggregate **plus** a typed per-store array: + +```go +// internal/api — Huma-registered; spec generated, never hand-written. +type BeadsConditionalWritesStatus struct { + Mode string `json:"mode" enum:"off,auto,require"` + Origin string `json:"origin" enum:"builtin,config,env"` + Effective string `json:"effective" enum:"off,active,degraded,fail_closed,pending_restart"` + Stores []ConditionalWriteStoreVerdict `json:"stores"` + Notices []RolloutNotice `json:"notices"` +} + +type ConditionalWriteStoreVerdict struct { + StoreID string `json:"store_id"` + Kind string `json:"kind" enum:"bd,native,sqlite-graph,caching,mem,file"` + Probe string `json:"probe" enum:"capable,incapable,unprobed"` + Latch string `json:"latch" enum:"capable,incapable,unlatched"` + Capable bool `json:"capable"` // probe ∧ latch, the value the write path actually uses + Reason string `json:"reason,omitempty"` +} +``` + +This is the daemon's **own latched snapshot** — boot-resolved mode, real per-store latches, retained notices — not a re-derivation. From this stage, `gc doctor` queries the live API whenever the city is up and renders that snapshot verbatim (probe *and* latch columns now both real); local re-resolution with the 12.1 banner becomes the fallback for a stopped city only. Doctor and the dashboard agree by construction because they render the same array. + +### 12.6 Runbook entries + +**Break-glass: disable CAS during an incident.** +The supported whole-city rollback is a config edit plus restart — the flag is process-latched, there is no hot flip: + +```toml +# city.toml +[beads] +conditional_writes = "off" # was "require"; restart the city to apply +``` + +`GC_BEADS_CONDITIONAL_WRITES` exists for deployments where config is baked and immutable (the hosted/crucible model — its named consumer). Its scope is **per-process**: it affects only processes that read it at start. Setting it in the controller's unit does *not* change what an operator shell or an agent-invoked `gc hook` resolves — expect the two vantage points to disagree, and read the `origin=` field in the first log line before concluding anything. Grammar is exactly `off|auto|require`; any other spelling (`disable`, `false`, `Require `) **fails the process at startup** naming the var, the raw value, and the grammar — a break-glass that silently no-ops at 2am is a failed break-glass. After the incident, remove the var: the `env_overrides_config` notice in doctor/status is the standing indicator that you forgot. + +**Restart after a bd upgrade (or downgrade).** +Capability is probed lazily and latched per store instance; nothing is persisted (restart re-probes live state — no status files). Upgrading bd in place does **not** clear an existing incapable latch: the store stays DEGRADED (auto) or refusing (require) until the process restarts. Doctor makes this legible as `probe=capable latch=incapable` — the fix line it prints is "restart to re-probe", not "reinstall bd". A downgrade in place trips the unknown-flag classifier on the next CAS write, flips the latch incapable mid-run, and fires the degraded event once; fix PATH/version, then restart. + +**Pending restart after a config edit.** +Editing `conditional_writes` on a running city records the pending-restart notice at reload (`pending restart: conditional_writes require (city.toml) != off (latched at start)`); doctor renders it as a WARNING. New components constructed after the reload still receive the **boot** value — that is the latch working, not a bug. Restart to apply. + +**`require` on the deployed sqlite topology.** +Forbidden until the sqlite `CompareAndSetMetadataKey` integration test (a blocking deliverable of the C4/C6 PR) has soaked against the deployed store shape. Until then, run `auto` and watch the graph-store row in doctor. And the fleet-scoped invariant, stated plainly: CAS mutual exclusion holds only when **every** writer to a ledger is CAS-active or exactly one writer exists — one node at `off`, one older binary, or one Auto-degraded host re-opens the races for everyone. Doctor warns on exactly this combination (auto + DEGRADED + declared multi-writer topology); treat that warning as "the flag is currently decorative on this ledger." + +## 13. Migration of existing ad-hoc flags + +Stages 1–4 unavoidably leave two flag mechanisms in the tree: `internal/rollout` and the legacy pile (`cmd/gc/feature_flags.go`, `internal/api` `syncFeatureFlags`, two package-global `atomic.Bool`s, and ~8 divergent `GC_*` env parsers). The failure mode is not that the old code exists — it is that the old code keeps *recruiting*: an agent adding flag #3 greps for "feature flag", finds `applyFeatureFlags` with seven call sites versus `internal/rollout` with one consumer, and copies the global-setter pattern. This section makes the old mechanisms frozen at stage 1, absorbed on a committed schedule, and un-copyable in between. + +### 13.1 Stage-1 freeze: the legacy mechanisms stop growing before they shrink + +Both freeze tests land in the stage-1 PR, before any migration code. They are ratchets: shrinkage requires a baseline edit (loud, reviewed, trivially approved); growth fails CI naming the offending file. + +**13.1.1 Legacy flag-mechanism golden list.** A boundary test in `cmd/gc` (same shape as `TestGCNonTestFilesStayOnWorkerBoundary`, `cmd/gc/worker_boundary_import_test.go:11`) walks non-test source and fails on any reference to the four legacy symbols beyond a checked-in inventory: + +```go +// cmd/gc/legacy_flag_freeze_test.go — TEMPORARY: deleted in stage 5 when the inventory hits zero. +var legacyFlagInventory = map[string]int{ // file → reference count; shrink-only + "cmd/gc/feature_flags.go": 1, // applyFeatureFlags definition + "cmd/gc/cmd_start.go": 1, // :673 + "cmd/gc/controller.go": 1, // :923 + "cmd/gc/cmd_agent.go": 2, // :52, :70 + "cmd/gc/cmd_sling.go": 1, // :247 + "cmd/gc/api_state.go": 1, // :1808 (reload path) + "cmd/gc/doctor_provider_catalog.go": 1, // :146 + "internal/api/server.go": 3, // syncFeatureFlags def :229 + calls :197, :203 +} +// Frozen symbols: applyFeatureFlags, syncFeatureFlags, +// formula.SetFormulaV2Enabled, molecule.SetGraphApplyEnabled. +``` + +Test files are frozen by per-package count ceiling (not file inventory — `internal/molecule` alone has 44 setter save/restores today and enumerating them buys nothing). A new test using `SetFormulaV2Enabled` in a package whose ceiling is met fails with "use rollout.ForTest(t, rollout.WithFormulaV2(...)) instead". + +**13.1.2 `GC_*` env-read frozen baseline.** A registry-driven AST lint in `internal/rollout` (precedent: `TestNoLeakVectorReadsAtPackageInit`) walks every package for `os.Getenv`/`os.LookupEnv` calls whose string-literal argument matches `^GC_`, and fails unless the (file, var) pair is in one of three buckets: + +1. `internal/testenv`'s documented test-gate vars; +2. a registry `Spec.EnvOverride` read inside `rollout.Resolve` (the only production home for flag env reads); +3. the checked-in baseline `internal/rollout/gc_env_baseline.go` — an enumerated `[]envReadSite{{File, Var}}` covering today's identity/path/creds/tuning reads (`GC_DOLT_ARCHIVE_LEVEL`, `GC_EVENTS_ROTATION_MAX_SIZE_BYTES`, the supervisor re-export sites, etc.). + +Non-literal env names in those calls are forbidden outside `internal/testenv`. A shadow flag now costs a reviewed baseline edit in a CODEOWNERS-adjacent file — mechanically more expensive than writing a Spec. Unlike the golden list, **this test is permanent infrastructure**: it outlives the migration and guards against flag #9 arriving as a bare `os.Getenv("GC_SKIP_EPOCH_VERIFY")`. + +### 13.2 formula_v2: Spec on day one, code migration as a committed bead + +The registry is born at N=2: the formula_v2 Spec registers in the stage-1 PR, months before its code migrates, so the legacy mechanism sits inside the anti-rot regime from day one and stage-5 slippage trips the Spec's own teeth (nightly radar files against the Owner bead; any PR touching `registry.go` hard-fails on the past-due entry). + +```go +// internal/rollout/registry.go — registered in stage 1 +{ + Key: "daemon.formula_v2", + Category: InfraMigration, + ConfigPath: "daemon.formula_v2", // *bool, nil → enabled (default-ON kill of the v1 path) + EnvOverride: "", // no env var exists today; none is added + Default: BoolDefault(true), + Owner: Owner{Bead: "ga-XXXXX", GitHub: "@gastownhall/gascity-flags"}, + Expires: "…", // mandatory: infra-migration is never immortal + VersionAnchor: "formula-v1 removal floor", + SelectsBetween: [2]string{"graph-compiled v2 molecules (graph-apply instantiation)", "sequential v1 step execution"}, + Justification: "selects between two mechanical formula-materialization transports during the v1→v2 migration; invisible to prompts", +} +``` + +The **stage-5 code migration is a blocking bead in the same milestone as CAS**, not a "follows later" note. Its deletion inventory (all verified against the current tree): + +| Deleted | Replaced by | +|---|---| +| `cmd/gc/feature_flags.go` + all 7 call sites (13.1.1 table) | `Flags.FormulaV2()` resolved once in `loadCityConfig*`, threaded by DI | +| `internal/api/server.go` `syncFeatureFlags` (:197, :203, :229) | server options struct carries `rollout.Flags`; the server never re-resolves | +| `formula` package `formulaV2Enabled` atomic.Bool + `SetFormulaV2Enabled` | explicit parameter, the existing `ValidateHostRequirements(f, formulaV2Enabled bool)` shape | +| `internal/molecule/graph_apply.go:25` `graphApplyEnabled` atomic.Bool + `SetGraphApplyEnabled`/`GraphApplyEnabled` | field on the molecule/Instantiate options struct | +| `internal/formulatest/v2.go` `LockV2ForTest` mutex + helpers | nothing — per-instance values need no process mutex | +| ~44 setter save/restores in `internal/molecule` tests, plus `internal/formula` (`compile_test.go`, `requirements_test.go`, `testhelper_test.go`), `internal/graphroute`, `internal/dispatch`, `internal/api/handler_sling_test.go` | `rollout.ForTest(t, rollout.WithFormulaV2(false))` — compile-time-typed, `t.Parallel`-safe | + +The `daemon.formula_v2` config field, its accessor, and its existing per-field `mergeFragment` preservation branch (`compose.go:1042`) are **not** deleted in stage 5 — they are the flag's config home until the flag itself graduates to deletion under its own version-anchored trigger, at which point `daemon.formula_v2` gets its own tombstone. + +### 13.3 Absorbing the env one-offs: EnvSemantics preserves shipped precedence + +The two legacy env gates have **opposite precedence today**, and absorption must not silently unify them — a precedence flip on a shipped operator interface is a breaking change, never a migration side effect: + +| Env var | Config home | Precedence today (verified) | `Spec.EnvSemantics` | Category | +|---|---|---|---|---| +| `GC_DOLT_AUTO_GC_ENABLED` | `[dolt] auto_gc_enabled` (`*bool`) | fills **only when config is nil** — explicit config wins (`dolt_start_managed.go:972`) | `EnvFillsNil` | infra-killswitch (no Expires; long-lived allowed) | +| `GC_EVENTS_ROTATION_ENABLED` | `[events.rotation] enabled` | set env **wins over config**; invalid warns and keeps config (`providers.go:998`) | `EnvOverrides` | infra-killswitch | +| `GC_BEADS_CONDITIONAL_WRITES` | `[beads] conditional_writes` | (new) | `EnvOverrides` | infra-rollout | + +`Resolve` honors the per-Spec semantics: + +```go +switch spec.EnvSemantics { +case EnvFillsNil: // legacy contract: env is a default, config is authoritative + if envOK && !explicitInConfig { + val, origin = envVal, OriginEnv + } +case EnvOverrides: // break-glass contract: env wins, contradiction is push-loud + if envOK { + if explicitInConfig && envVal != cfgVal { + notices = append(notices, contradictionNotice(spec, cfgVal, envVal)) // + startup log + typed event (§ resolution) + } + val, origin = envVal, OriginEnv + } +} +``` + +Absorption mechanics, both flags, stage 5: register the Spec (ConfigPath reflection-verified against the existing toml tags — no config field moves), reroute the `os.Getenv` read from `dolt_start_managed.go` / `providers.go` into `Resolve`, delete `parseEnvAutoGCEnabled` and `parseEventsRotationEnabled` in favor of the one shared bool grammar, and register both vars in `LeakVectorVars`. **Grammar superset test:** the shared bool grammar is extended with `enabled`/`disabled` (case-insensitive, trimmed) specifically so it is a strict superset of both legacy parsers; a unit test feeds every spelling either legacy parser accepted (`ParseBool` spellings, `ON`/`OFF`, `y`/`yes`/`enabled`, …) and asserts identical results — no operator's working unit file breaks on upgrade. + +Out of scope, deliberately: the sibling numeric tuning vars (`GC_DOLT_MAX_CONNECTIONS`, `GC_EVENTS_ROTATION_MAX_SIZE_BYTES`, `GC_EVENTS_ROTATION_RETAIN_AGE`, …) are configuration overlays, not rollout gates — they do not enter the registry, but they ARE pinned in the 13.1.2 baseline so they cannot multiply silently. The supervisor child-env re-export of `GC_DOLT_AUTO_GC_ENABLED` (`beads_provider_lifecycle.go:2003/2039`) is part of the flag's shipped interface and is untouched; its read sites live in the baseline. If the fills-nil/overrides pair is ever unified on env-wins, that is a standalone, release-noted breaking change with a doctor callout — with its own migration section, not this one. + +### 13.4 The graph_workflows tombstone + +`daemon.graph_workflows` is a live deprecated alias today: field at `config.go:2297`, honored only when `formula_v2` is absent (`config.go:4290`), with its own clause in the merge special case (`compose.go:1042`). Stage 1 registers the retirement **obligation** (a bead linked from the formula_v2 Owner bead — it cannot be forgotten); stage 5 executes it: + +```go +// internal/config/undecoded.go — mechanism ships in stage 1; this entry is minted in stage 5 +var retiredKeys = []RetiredKey{ + { + Key: "daemon.graph_workflows", + RemovedIn: "v1.5", // gc version anchor at the stage-5 merge — never a wall-clock date + Message: "daemon.graph_workflows was a deprecated alias for daemon.formula_v2 " + + "(alias removed in v1.5); set daemon.formula_v2 instead", + }, +} +``` + +A retired key downgrades from fatal-unknown-key to this warning; the alias-honoring branch at `config.go:4290` and the `graph_workflows` clause at `compose.go:1042` are deleted in the same PR. The nightly radar (not PR CI) flags the tombstone for deletion once the current version anchor exceeds `RemovedIn+1` — version-anchored because this fleet deploys branches, and "one release" has no machine meaning here. + +### 13.5 Order and exit criteria + +Freeze (stage 1) → CAS ships on the new subsystem (stages 2–4) → absorption (stage 5, committed bead). The milestone is closed only when: + +- `grep -rn "applyFeatureFlags\|syncFeatureFlags\|SetFormulaV2Enabled\|SetGraphApplyEnabled\|LockV2ForTest" --include="*.go"` returns zero hits, tests included; +- `cmd/gc/feature_flags.go` and `internal/formulatest/v2.go` are deleted, and `cmd/gc/legacy_flag_freeze_test.go` is deleted with them (its inventory reached zero — a freeze test guarding nothing is debt too); +- `os.Getenv`/`LookupEnv` reads of `GC_DOLT_AUTO_GC_ENABLED` and `GC_EVENTS_ROTATION_ENABLED` exist only inside `rollout.Resolve`; +- `graph_workflows` appears only in `retiredKeys` and its test; +- the 13.1.2 env-read baseline test remains, permanently, as the tax collector for any future shadow flag. + +## 14. Rejected alternatives and red-team dispositions + +Rejection here followed three tests, applied uniformly: (1) **N≥1** — a mechanism with zero present consumers does not ship (`no premature abstraction`); (2) **deterministic teeth** — any merge-blocking check must produce the same verdict for the same commit on any day; (3) **honest claims** — an enforcement claim CI cannot actually make is reworded to what review enforces, never left inflated. Every alternative below failed at least one. + +### 14.1 Rejected alternatives + +#### 14.1.1 Central `[features]` table + +```toml +# REJECTED +[features] +beads_conditional_writes = "require" +daemon_formula_v2 = true + +# ADOPTED — the flag lives on the subsystem it gates +[beads] +conditional_writes = "require" # beside bd_compatibility +``` + +Rejected because it fights the config system's native idiom on three fronts. Progressive activation is *by section presence* (`md.IsDefined`), so a flag divorced from its owning section stops participating in the activation model its subsystem uses. A new `[features]` table is a **new section**, which under `mergeFragment`'s per-section semantics needs its own merge wiring from day one — the "central table is simpler" intuition is exactly backwards, since `[beads]` placement reuses the existing table plumbing plus one per-field preservation branch (§4). And co-location is the real reviewer affordance: `conditional_writes` sits one line from `bd_compatibility`, whose version-gate semantics it will eventually merge into at graduation. The one thing a central table buys — discoverability — is recovered losslessly by the registry, `gc doctor`'s Rollout Flags section, and (stage 4) the status wire, which enumerate every flag regardless of which section holds it. + +#### 14.1.2 Per-consumer knobs + +```toml +# REJECTED +[beads] +conditional_writes_dispatch = "require" # C4 epoch fence +conditional_writes_drain = "auto" # C6 reservation +conditional_writes_api = "off" # C2 If-Match +``` + +Rejected because it configures a race back into existence. `gc.control_epoch` and `gc.drain.reserved_by` can live on the *same store*; `dispatch=require, drain=off` makes one process a CAS writer and a legacy writer against one ledger — the mixed-writer clobber that §9's fleet invariant exists to forbid, now expressible in TOML and green in every test. It also triples the lifecycle surface (three Specs, three graduation predicates, three tombstones, a 12-cell operational matrix in doctor) and creates permanent partial states with no forcing function to collapse them. Staged adoption is real but it is a *code-landing* sequence (C4/C6 in stage 3, C2 in stage 4), not a config surface: an operator opting in opts the whole write discipline in. + +#### 14.1.3 Plain-bool gate + +```go +// REJECTED +ConditionalWrites *bool `toml:"conditional_writes,omitempty"` // on|off cliff + +// ADOPTED +func (b BeadsConfig) ConditionalWritesMode() rollout.Mode // Off | Auto | Require +``` + +Rejected because a bool makes heterogeneous-fleet rollout a cliff between "off" (no protection) and "refused writes" (one stale bd bricks the drain path). The middle state is not decoration: `Auto` = *use CAS where the resolved store is capable, degrade loudly elsewhere* is what makes incremental adoption survivable, and `Require` = *fail closed* is a distinct contract, not "very on". The repo's own correctness rollouts already walk this shape (`GC_WORK_RECORD_ENFORCE` warn→enforce, `GC_WISP_GC_*` dry-run→act). Kill-switches that genuinely are binary keep the `*bool` idiom — the two value kinds coexist in the registry by design (§2). + +#### 14.1.4 RemovalTrigger predicate DSL + +```go +// REJECTED: a predicate mini-language, evaluator, and "version-bound flag" +// classifier — generic machinery with exactly one instantiation. +type RemovalTrigger struct{ Expr string } // "BD_PREV_VERSION >= 1.2.0" … + +// ADOPTED: one plain test in the TestBDVersionPins family (~20 lines). +func TestConditionalWritesGraduationStages(t *testing.T) { + pins := loadDepsEnv(t) + spec := rollout.SpecFor(t, "beads.conditional_writes") + // Stage 1: installable bd crossed the floor, default still Off. + if deps.CompareVersions(pins.BDVersion, bdConditionalWritesMinVersion) >= 0 && + spec.Default == rollout.Off && !flipDeferredWithin(spec, pins) { + t.Fatalf("bd %s has --if-revision: flip default Off→Auto (owner %s/%s) or set FlipDueBy", + pins.BDVersion, spec.OwnerBead, spec.OwnerHandle) + } + // Stage 2: min-supported floor crossed, flag still registered. + if deps.CompareVersions(pins.BDPrevVersion, bdConditionalWritesMinVersion) >= 0 { + t.Fatalf("min-supported bd %s has --if-revision: DELETE flag+accessor+config field+legacy branches; mint tombstone RemovedIn=%s", + pins.BDPrevVersion, pins.Anchor) + } +} +``` + +Rejected at N=1. The hypothetical second version-anchored flag will likely key on a *different* anchor source (a `go.mod` library version, not `deps.env`), so the DSL would take its first breaking rewrite at N=2 — the classic framework-before-second-consumer failure. The plain test has identical teeth (deterministic, fires only when someone edits `deps.env`, names the owner) and zero grammar to maintain. Extract a shared helper if and when a second such test exists. + +#### 14.1.5 Soft cap (~8 active non-Stable flags fails CI) + +Rejected as N=0 governance. The complete historical inventory of flag-shaped things in this tree is ~8–10; the registry ships with 2. The cap's only guaranteed firing scenario is an engineer adding a legitimate kill-switch *during an incident*, where the fix-under-fire is bumping the constant — training everyone that the cap is editable friction. Worse, the cap actively sharpened the `Stability=Stable` abuse (LD-2): promoting a flag to Stable freed cap headroom *and* dodged expiry in one line. Deleting the cap and the Stability enum together closes that loop. Anti-rot is carried by per-flag mechanisms that scale with N instead of gating it: mandatory Expires + version anchors for rollout/migration categories, the nightly radar filing beads against owners, and CODEOWNERS on `registry.go`. If flag-count anxiety ever materializes, the answer is a doctor INFO line, not a build failure. + +#### 14.1.6 Daemon-only EnvOverride restriction (red-team OO-8 sub-fix) + +Proposed: restrict `EnvOverride` on process-latched correctness flags to the daemon entry point, so `gc hook`/`gc sling` CLI paths resolve config-only and cross-process mode divergence becomes inexpressible. **Rejected for v1.** It gives the resolver entry-point awareness — a new resolution axis ("which binary am I?") threaded through `ResolveOptions` and every loader — to prevent a divergence the adopted fixes already make visible in the first log line: break-glass scope is documented as per-process (§5), every refusal/degrade diagnostic carries `mode=… origin=…`, and an env override contradicting explicitly-set config emits a startup structured log plus typed event. The threat model (operator break-glasses the controller unit while agent-invoked CLI paths still resolve `require`) is real but diagnosable in seconds under the adopted design. Revisit trigger: one actual bifurcated incident where origin-tagged diagnostics proved insufficient — then the restriction returns as a per-Spec field, not a resolver rewrite. + +#### 14.1.7 Direction-explicit `force-off` env grammar (red-team PV-4 sub-fix) + +```bash +# REJECTED: a second, direction-aware grammar for downgrades +GC_BEADS_CONDITIONAL_WRITES=force-off + +# ADOPTED: mode names only; anything else fails startup on correctness flags +GC_BEADS_CONDITIONAL_WRITES=off|auto|require +``` + +Proposed so that downgrading a declared `require` could never be a typo'd truthy value. **Rejected as superseded**, not as wrong: the adopted grammar is *stricter* than the proposal's premise. Mode-flag env vars accept only the three literal mode names — no truthy spellings exist for tri-state flags at all — and an unparseable value on a correctness-category flag fails startup fast, naming the var, the raw value, and the grammar (§5). A typo therefore cannot downgrade anything silently; it stops the process with instructions. Adding `force-off` on top would be a second grammar to document, parse, and test, defending against a scenario with no remaining teeth. + +#### 14.1.8 Other mechanisms deleted or deferred (cross-reference) + +Each of these appeared in an earlier draft and was removed by a specific finding; the surviving decision lives in the cited section. + +| Mechanism | Fate | Killed by | Survivor | +|---|---|---|---| +| `Latch (process\|reload)` Spec field | Deleted | PV-7, OO-1, T-2, Y-5 | v1 is process-latched for all flags (§6) | +| `Stability` enum, `IntroducedIn`, `GraduationCriterion` | Deleted | LD-2, Y-5 | Per-category lifecycle rules + `GraduatedIn` stamped at flip (§11) | +| `WithConditionalWrites(mode)` store option | Deleted | T-3 | Factory stamps mode; `ResolveConditionalWriter(store)` takes no mode param (§6) | +| `WithBDCapabilityProbe` injection seam | Deleted | T-7 | Probe rides the store's existing `CommandRunner` (§7) | +| `withoutConditionalWrites(store)` test wrapper | Deleted | T-5 | `mem.DisableConditionalWrites` instance toggle (§10) | +| String-keyed `ForTest(t, "key", val)` | Deleted | T-9 | Generated typed `With*` option funcs (§10) | +| Generic registry-driven merge-coverage reflection harness | Deleted | Y-9 | Hand-written per-flag merge test on the `daemon.formula_v2` template (§4) | +| Wall-clock `Expires` in merge-blocking CI | Deleted | LD-1, T-6, Y-2 | Nightly radar + diff-gated hard failure (§11) | +| Five-value Origin (`builtin\|pack\|city\|fragment\|env`) + `/v0/config/explain` extension | Deferred | Y-3 | Three-value Origin `builtin\|config\|env`; per-layer costed as new compose.go plumbing when asked for (§5) | +| Status-wire flag struct in slice 1 | Deferred to stage 4 | Y-8, OO-5 | Rides the C2/go.mod-bump PR's unavoidable spec regen (§12) | +| `ModelImprovementJustification` as a CI-checked tooth | Demoted to documentation | LD-8, Y-11 | CODEOWNERS review on `registry.go` is the semantic gate (§13) | + +### 14.2 Red-team disposition table + +Five lenses, 57 findings, zero findings rejected outright; two *sub-fixes* rejected (§14.1.6, §14.1.7). IDs number each lens's findings in review order. Legend: **A** = adopted as specified; **A/am** = adopted with an amended mechanism; **A/st** = adopted, lands in a named later stage; **Rej** = sub-fix rejected. + +#### principle-violation + +| ID | Sev | Finding | Disp. | Resolution | +|---|---|---|---|---| +| PV-1 | BLK | Whole-table `[beads]` LWW fragment merge silently wipes `conditional_writes` (require→off) | A | Mandatory per-field `IsDefined("beads","conditional_writes")` branch in `mergeFragment` + hand-written merge regression test per flag (§4); generic harness dropped per Y-9 | +| PV-2 | HIGH | Import-edge prompt-boundary test unimplementable (rendering lives in cmd/gc `package main`; `PromptContext.Env` leaks values without imports) | A/am | Extract `internal/prompt` (PR-1a) so the forbidden edge exists; registry-driven AST lint over PromptContext construction/Env writes/FuncMaps; value-flow half honestly stated as review-governed (§13) | +| PV-3 | HIGH | Registry costs incentivize bypass via new bare `GC_*` getenv or `*bool` idiom | A | Frozen-baseline `GC_*` env-read inventory test + golden-list freeze on legacy flag mechanisms, both in stage 1; reverse parity mechanical for `rollout.Mode` only; `*bool` classification stated review-governed (§8) | +| PV-4 | MED | Stale env var silently downgrades an explicit `require` | A + Rej | (a) startup log + typed event when env contradicts explicit config, (b) origin on status wire — adopted (§5); (c) `force-off` spelling rejected → §14.1.7 | +| PV-5 | MED | Registry polices form, not semantics — judgment-in-Go gate wearing infra clothes passes every test | A | Required `SelectsBetween [2]string`; CODEOWNERS on `registry.go`; litmus questions in file header + PR template; design text says review-with-teeth explicitly (§13) | +| PV-6 | MED | "Per-agent scope inexpressible by construction" overstated — `config.Agent` can express it | A | Claim reworded; reflection test fails if Agent/AgentPatch/AgentOverride gains a `rollout.Mode` field; contributor doc names the forbidden shape regardless of declaration site (§2) | +| PV-7 | LOW | Reload two-truths: components hold different snapshots invisibly | A | Fix option (a) taken: v1 process-latched for all flags, reload machinery deleted, `Latch` field deleted (§6) | + +#### testability + +| ID | Sev | Finding | Disp. | Resolution | +|---|---|---|---|---| +| T-1 | HIGH | The claimed `run()` composition root does not exist (~30 independent config-load sites) | A | Resolve folded into `loadCityConfig`/`loadCityConfigWithBuiltinPacks`; factory stamps mode onto every store it opens; entry-point tests for controller/hook/sling/api (§6) | +| T-2 | HIGH | `latch=process` self-contradictory with hot-reload re-Resolve | A | Whole-process latch; reload carries the boot snapshot into all later components; regression test: boot Off → rewrite Require → reload → new store observes Off + Notice (§6) | +| T-3 | MED | Mode has two homes (store option and seam parameter) that tests can wire contradictorily | A | Single home: factory-stamped; `ResolveConditionalWriter(store)` reads it; `WithConditionalWrites` deleted (§6) | +| T-4 | MED | Fake-store revision discipline unspecified — green CI predicts nothing about bd | A | Store-agnostic conformance suite (Mem/File/Caching/sqlite in unit CI; BdStore under `//go:build integration`); bump discipline is the interface doc comment; slots into the PR #3714 contract-test system (§10) | +| T-5 | MED | `withoutConditionalWrites` wrapper silently strips all five optional store interfaces | A | Wrapper deleted; per-instance `DisableConditionalWrites` toggle keeps the interface set intact (§10) | +| T-6 | MED | Date-based Expires is a zero-diff CI time bomb | A | Merged into LD-1 disposition (§11) | +| T-7 | LOW | Duplicate probe seams let tests wire probe/runner contradictions | A | One seam: probe runs through the existing `CommandRunner`; `WithBDCapabilityProbe` deleted (§7) | +| T-8 | LOW | Exported mutable `Registry` slice leaks mutations across parallel tests | A | Canonical slice unexported behind a read-only accessor; validator and `ForTest` take a `[]Spec` parameter (§2) | +| T-9 | LOW | String-keyed `ForTest` reintroduces stringly reads; flag removal degrades to runtime failure | A | Typed `With*` option funcs generated per accessor; deletion breaks tests at compile time (§10) | + +#### cas-correctness + +| ID | Sev | Finding | Disp. | Resolution | +|---|---|---|---|---| +| CC-1 | HIGH | Reservation CAS collapses idempotent re-entry into a false loss → stranded undrainable members | A | Exit-9 contract: re-read; `current==control.ID` → success (self-win); other → skip; preserves the drain.go three-outcome contract; MemStore re-entry + ambiguous-retry tests (§9) | +| CC-2 | HIGH | Attach epoch CAS ordering unspecified; both orderings have distinct wedge modes | A | CAS-LAST pinned; exit-9 loser wired into existing `isPartialAttemptAttachError`/`molecule_failed` recovery; concurrent-Attach integration test sharing an idempotency key (§9) | +| CC-3 | HIGH | Ambiguous transport errors may be committed CAS writes; blind re-read converts self-wins into false losses | A | Per-consumer ambiguity contract: writer-identifying values must self-win-check on re-read; epoch tolerates false loss only via `findExistingAttach` idempotency, documented on the seam; injected-ambiguity test via fake runner (§9) | +| CC-4 | HIGH | No sqlite ConditionalWriter exists; the deployed controller is exactly where the fence matters | A | Promoted to blocking deliverable of the C4/C6 PR + integration test against the deployed store shape; doctor renders the graph-store verdict; runbook forbids `require` on the deployed topology until it soaks (§9, stage 3) | +| CC-5 | MED | Mixed-writer fleets (or one reloaded process) silently re-open the races | A | Fleet-scoped invariant in design + runbook; whole-process latch pins reload; doctor warns on DEGRADED under declared multi-writer topology (§9) | +| CC-6 | MED | Latching incapable on bare exit 13 conflates capability absence with per-write policy refusals | A | Latch only when the machine-parseable body code equals `conditional-write-unsupported`; bare 13 → typed non-latching refusal; both encoded in classifier tests (§7) | +| CC-7 | MED | Pre-#4682 bd never emits 13 — it rejects `--if-revision` as an unknown flag; the loud-degrade cell was unreachable | A | Classifier maps usage/unknown-flag errors mentioning `--if-revision` → `ErrConditionalWriteUnsupported` + latch; test for the old-bd rejection string (§7) | +| CC-8 | MED | Divergent conflict granularity per backend + emulation starvation on metadata-hot control beads | A | Granularity contract on the interface (assume neither value- nor revision-level semantics); bounded emulation loop + typed exhaustion error; bd-sql value-CAS (`ReleaseIfCurrent` template) evaluated (§9) | +| CC-9 | MED | CachingStore refresh-or-patch template leaves stale revisions → exit-9 livelock | A | Evict, never patch: delete cache entry on CAS-success-with-failed-refresh and on every PreconditionFailed; livelock regression test is a merge gate of the stage-2 PR (§9) | +| CC-10 | LOW | Single-verb help probe + construction-time subprocess tax on short-lived CLI paths | A | Lazy memoized probe on first conditional write; greps all four verb helps; doctor renders probe verdict and runtime latch separately (§7) | + +#### lifecycle-debt + +| ID | Sev | Finding | Disp. | Resolution | +|---|---|---|---|---| +| LD-1 | BLK | Wall-clock Expires reds every PR with zero diff; fleet treats red as a stall; trains date-bumping | A | No bare date-vs-`time.Now()` in the Check path; version-anchored deterministic tests; wall-clock staleness → nightly non-blocking radar filing beads; expiry hard-fails PR CI only when `registry.go` is in the diff; tombstones version-anchored (§11) | +| LD-2 | HIGH | `Stability=Stable` is an immortality hatch; the cap sharpens the incentive | A | Stability enum deleted; per-category rules in `registry_test`: rollout/migration may never be immortal, terminal state is deletion; only killswitch is long-lived; cap deleted (§11, §14.1.5) | +| LD-3 | HIGH | Removal predicate keyed to `BD_PREV_VERSION`, which historically never moves; no terminal-state check | A | Two-stage test: `BD_VERSION` crosses floor ⇒ demand Off→Auto flip; `BD_PREV_VERSION` crosses ⇒ demand deletion of flag/accessor/config field/legacy branches; `GraduatedIn` recorded at flip (§11) | +| LD-4 | HIGH | Nothing forces the stage-5 formula_v2 migration; the old mechanism keeps recruiting | A | Stage-1 freeze (golden-list boundary test + `GC_*` baseline); formula_v2 Spec registered day one with its own expiry so slippage trips its own teeth; migration is a committed same-milestone blocking bead; `graph_workflows` tombstone obligation registered (§8, stage 1/5) | +| LD-5 | MED | Owner is a decorative bead ID no test can resolve to a human | A | Dual Owner (bead ID + GitHub handle/team); `registry.go` under CODEOWNERS with a named human team (§2) | +| LD-6 | MED | Trigger firing forces a rush Require-path flip inside an unrelated (possibly CVE) bump PR | A | `FlipDueBy = current anchor + 1 bump` — machine-checked, diff-visible, bounded deferral; silent-forever stays impossible (§11) | +| LD-7 | MED | Built-in default lives in two files (Spec.Default vs accessor mapping) with no equality check | A | `registry_test` constructs a zero-value `config.City` and asserts each flag's typed accessor equals `Spec.Default` (§2) | +| LD-8 | LOW | Non-empty `ModelImprovementJustification` is compliance theater | A/am | Field kept as documentation; enforcement claim relocated to the CODEOWNERS human gate; the suggested min-length/content lint not taken — a content lint is the same theater with more grammar (§13) | +| LD-9 | LOW | Tombstone lifetime "one release" is meaningless in a branch-deployed fleet | A | Tombstones minted with `RemovedIn=`; radar flags for deletion once the current anchor exceeds `RemovedIn+1`; no wall clock (§11) | + +#### operability-observability + +| ID | Sev | Finding | Disp. | Resolution | +|---|---|---|---|---| +| OO-1 | BLK | Hot-reload hands a re-Resolved mode to new stores while old stores hold the boot mode — legacy and CAS writers race in one process | A | Whole-process latch; reload path carries the boot-resolved Flags into all later-constructed components; persistent pending-restart Notice → doctor WARNING; regression test pinned (§6) | +| OO-2 | HIGH | Invalid config value (`"requre"`) silently resolves to Off; the cited precedent is itself broken | A | Registry-driven `ValidateSemantics` walk rejects out-of-enum values fatally, naming field/value/allowed set; accessors only ever map `""`; pre-existing `NormalizedBDCompatibility` silent-normalize fixed in the same PR (§4) | +| OO-3 | HIGH | Doctor is a re-derivation (its shell env, its PATH, no view of daemon latches) and can lie in both directions | A/st | Slice 1: explicit local-resolution banner; stage 4: doctor queries the live API and renders the daemon's own latched snapshot; latched-vs-on-disk divergence rendered as pending-restart (§12) | +| OO-4 | HIGH | Break-glass env fails open on typo — one unread stderr line, then the wrong mode | A | Unparseable env on a correctness flag fails startup fast, naming var/raw value/grammar; Notices retained on the Flags value for the process lifetime (§5) | +| OO-5 | MED | The `{mode, capable, active}` triple cannot express a mixed fleet | A/st | Wire type is aggregate verdict + typed per-store array `{store_id, kind, capable, reason}` + origin + retained Notices; rides the stage-4 regen (§12) | +| OO-6 | MED | DEGRADED — the most-likely-to-persist state — emits nothing pushable | A | Typed `beads.conditional_writes.degraded {store, mode, reason, bd_version}` event, registered via `events.RegisterPayload`, latched once per store (§12, stage 2/3) | +| OO-7 | MED | Uniform env-wins silently inverts `GC_DOLT_AUTO_GC_ENABLED`'s fills-nil precedence on absorption | A | Per-Spec `EnvSemantics (overrides\|fills-nil)` preserves each legacy flag's contract; unification only as an explicit release-noted breaking change; env-contradicts-config Notice/event (§5) | +| OO-8 | MED | Env is per-process, config per-city; two processes of one city can resolve opposite modes undetected | A + Rej | Core adopted: break-glass documented per-process; mode+origin in every refusal/degrade diagnostic; env-contradicts-config startup event. Daemon-only EnvOverride sub-fix rejected → §14.1.6 | +| OO-9 | LOW | No exit-code contract for the doctor section | A | Pinned by test: FAIL-CLOSED and radar-surfaced past-due items = ERROR + nonzero exit; DEGRADED = warning + exit 0 (§12) | +| OO-10 | LOW | Resolve's Notices have no defined lifetime; origin facts evaporate after startup | A | Notices retained on the Flags value for the process lifetime; rendered by doctor and (stage 4) the status wire (§5) | + +#### yagni-scope + +| ID | Sev | Finding | Disp. | Resolution | +|---|---|---|---|---| +| Y-1 | HIGH | The registry ships with one flag — an N=1 abstraction against the repo's two-implementations rule | A/am | Fix option (b): two Specs registered day one (beads CAS + formula_v2, each with owner/anchor/expiry); the formula_v2 code migration is a committed same-milestone blocking bead whose slippage trips its own Spec's teeth (§8) | +| Y-2 | HIGH | Calendar-triggered CI failures wedge the autonomous merge fleet | A | Merged into LD-1 disposition (§11) | +| Y-3 | HIGH | Five-value Origin requires per-field provenance plumbing that does not exist; "extend explain" is bespoke | A | Origin collapsed to the three zero-loader-change values `builtin\|config\|env`; per-layer origin and the explain extension deferred and honestly costed as new compose.go plumbing (§5) | +| Y-4 | HIGH | "Settle env precedence once" retroactively changes a live production knob | A | Same mechanism as OO-7: per-Spec `EnvSemantics`; new flags default to overrides, absorbed flags keep their shipped precedence (§5) | +| Y-5 | MED | 13-field Spec is form-filling tax burying the two fields that matter | A/am | Five fields deleted (Stability, IntroducedIn, GraduationCriterion, Latch, plus the cap); Category *kept* — amended from taxonomy label to carrier of enforced per-category lifecycle rules; `SelectsBetween` added per PV-5 (§2) | +| Y-6 | MED | Predicate DSL built for one predicate | A | One plain Go test in the `TestBDVersionPins` family; no DSL → §14.1.4 | +| Y-7 | MED | Soft cap is governance for a population problem that does not exist | A | Cap deleted → §14.1.5 | +| Y-8 | MED | Four observability surfaces for a default-off experimental flag bloat the correctness PR | A | Slice 1 ships doctor only; status wire rides the stage-4 regen that Bead.Revision forces anyway; explain deferred (§12) | +| Y-9 | MED | Generic merge-coverage reflection harness has zero consumers and known `toml.MetaData` subtleties | A | Harness deleted; hand-written per-flag merge test on the `daemon.formula_v2` template; one-sentence lifecycle-doc rule covers future new-section flags (§4) | +| Y-10 | LOW | Import-boundary test oversold as making smuggling "structurally impossible" | A | Coverage stated honestly: CI blocks the naive import/AST paths; value-flow half is review-governed with the PR-template checklist item (§13, with PV-2's mechanisms) | +| Y-11 | LOW | Justification-as-test-enforced-string inverts its purpose | A | Same disposition as LD-8: documentation field; litmus lives in the file header and PR template; CODEOWNERS is the gate (§13) | +| Y-12 | LOW | CAS env override has no demonstrated consumer (process-latched ⇒ restart either way) | A | Kept with its consumer named in the Spec rationale: hosted cities with baked/immutable config (the crucible deployment model), where env is the only injectable surface (§5) | + +### 14.3 Audit summary + +57 findings across six lenses: 2 BLOCKERs and 9 HIGHs adopted with normative amendments (the fragment-merge preservation branch, whole-process latching, the sqlite ConditionalWriter promotion, and the deterministic lifecycle teeth being the four that materially reshaped the design); 0 findings rejected outright; 2 sub-fixes rejected with recorded revisit triggers (§14.1.6, §14.1.7); 11 mechanisms deleted and 3 deferred with named owners for their return conditions (§14.1.8). Every disposition above cites the section where the surviving mechanism is specified; if a future PR touches one of these seams, this table is the record of *why* the seam looks the way it does. + +## CAS rollout plan (staged) + +STAGE 1 — Subsystem + flag plumbing (no behavior change; flag inert). PR-1a: extract prompt rendering from cmd/gc package main into internal/prompt (mechanical move; enables the real import-boundary test). PR-1b: internal/rollout (unexported registry, Spec, Resolve with injected LookupEnv, typed Flags + ForTest With* options, Notices retained on Flags); TWO Specs registered day one (beads CAS infra-rollout + formula_v2 infra-migration, each with dual Owner, version anchor, expiry); BeadsConfig.ConditionalWrites field + pure accessor + jsonschema enum; the per-field IsDefined("beads","conditional_writes") preservation branch in mergeFragment + hand-written merge regression test; registry-driven load-time enum validation (and the bd_compatibility silent-normalize bugfix); Resolve folded into loadCityConfig/loadCityConfigWithBuiltinPacks; reload path carries the boot-latched snapshot + pending-restart Notice + regression test; registry tests (completeness, ConfigPath reflection, Default==zero-value-accessor equality, EnvOverride∈LeakVectorVars, per-category immortality rules, Agent-struct rollout.Mode guard); freeze tests (legacy flag-mechanism golden list; GC_* env-read frozen baseline); prompt-boundary import test + registry-driven AST lint; CODEOWNERS line for registry.go; gc doctor Rollout Flags section (local-resolution banner, pinned exit codes). GATE: make test + entry-point tests green; flag resolves but nothing consumes it. + +STAGE 2 — ConditionalWriter machinery in internal/beads (still no consumer). Interface + typed errors (PreconditionFailedError{Expected,Current}, ErrConditionalWriteUnsupported) with the revision-bump contract as the interface doc comment; BdStore --if-revision argv building + exit-code classifier (exit-9 defensive JSON parse; exit-13 latch ONLY on body code conditional-write-unsupported; unknown-flag-mentioning---if-revision → unsupported+latch; bare-13 → typed non-latching refusal); lazy memoized four-verb capability probe through the existing CommandRunner (no WithBDCapabilityProbe); dedicated CAS retry policy (re-read before re-attempt; bounded emulation loop + typed exhaustion; ambiguity self-win contract); factory stamps the resolved Mode onto every store it opens; ResolveConditionalWriter(store) seam; MemStore/FileStore native implementations + DisableConditionalWrites instance toggles; CachingStore forward + EVICT on success-with-failed-refresh AND on PreconditionFailed (livelock regression test is a MERGE GATE of this PR); NativeDoltStore delegation behind the library-version build reality; conformance suite over Mem/File/Caching in unit CI + BdStore under //go:build integration (slots into contract-test system); typed beads.conditional_writes.degraded event registered. GATE: conformance suite green across all in-process stores; classifier fake-runner tests cover 9/13-with-body/13-bare/unknown-flag/ambiguous-committed. + +STAGE 3 — C4 + C6 consumers (flag becomes real). BLOCKING deliverable: sqlite graph store CompareAndSetMetadataKey (single conditional UPDATE, ReleaseIfCurrent template) + integration test against the deployed store shape (staged against deploy/sqlite-b36-probe-attribution). C4: molecule.Attach read-compare-SetMetadata collapses to CompareAndSetMetadataKey on graphBeadStore(), CAS-LAST, exit-9 loser wired into isPartialAttemptAttachError/molecule_failed recovery; concurrent-Attach integration test sharing an idempotency key. C6: reserveDrainMember → CompareAndSetMetadataKey on drainMemberOwningStore(member) with the three-outcome self-win re-read (re-entry + ambiguous-retry MemStore tests). Doctor renders per-store verdicts incl. the graph-class store; runbook: fleet-scoped mixed-writer invariant + require forbidden on deployed topology until the sqlite test soaks; degraded event live. GATE: four-cell matrix tests per consumer; off-mode byte-identical assertion; soak on maintainer-city in auto before recommending require anywhere. + +STAGE 4 — beads library bump + C2 API. go.mod bump absorbs Bead.Revision on the wire in the SAME PR: genspec regen, three tracked OpenAPI copies, dashboard TS, make dashboard-check; typed If-Match Huma header (ETag=revision); apierr precondition_failed → HTTP 412 with expected/current; explicit conditional_writes_unsupported apierr when If-Match is presented while inactive (never silently ignore a precondition); no-If-Match requests keep legacy semantics. The status-wire beads_conditional_writes struct (aggregate verdict + typed per-store array + origin + retained notices) rides this PR's unavoidable regen; doctor switches to querying the live API when the city is up. GATE: TestOpenAPISpecInSync, dashboard-check, 412/If-Match handler tests. + +STAGE 5 — formula_v2 migration (committed same-milestone blocking bead, slippage trips its own registered Spec's lifecycle teeth). Delete cmd/gc/feature_flags.go, api server syncFeatureFlags, SetFormulaV2Enabled/SetGraphApplyEnabled atomic.Bools, formulatest.LockV2ForTest mutex, ~20 molecule_test save/restores; thread via Flags accessors/DI; absorb GC_DOLT_AUTO_GC_ENABLED and GC_EVENTS_ROTATION_ENABLED with EnvSemantics=fills-nil preserved (any precedence unification is a separate release-noted breaking change); register the graph_workflows tombstone with RemovedIn version anchor. + +GRADUATION (post-tag): add bdConditionalWritesMinVersion anchor to deps.env under TestBDVersionPins lockstep; probe switches from help-grep to version-compare; two-stage plain-Go lifecycle test enforces Off→Auto when BD_VERSION crosses the floor (FlipDueBy grace = +1 anchor bump for the version-bump PR) and DELETION (flag, accessor, config field, legacy read-then-write branches, tombstone mint) once BD_PREV_VERSION crosses; nightly radar files beads against the Owner for wall-clock staleness throughout. + +## Settled decisions + +### Registry shape (typed Spec) + +**Decision:** internal/rollout holds an UNEXPORTED canonical []Spec (read-only accessor; validator and ForTest take a []Spec parameter so subsystem tests use local synthetic registries). Spec is cut to fields with enforcement or operational teeth: Key; Category (closed enum infra-rollout|infra-migration|infra-killswitch — kept because it now carries enforced per-category lifecycle rules, see lifecycle decision); ConfigPath (reflection-verified against City toml tags); EnvOverride ("" or one GC_* name registered in testenv LeakVectorVars) + EnvSemantics (overrides|fills-nil); Default (registry_test asserts a zero-value config.City's typed accessor equals it — closes the two-homes drift); Owner (dual: bead ID + GitHub handle/team); Expires + VersionAnchor/removal floor (mandatory for rollout/migration, forbidden for killswitch); SelectsBetween [2]string naming the two mechanical code paths; Justification (documentation, not a CI tooth). DELETED: Stability enum, IntroducedIn, GraduationCriterion, Latch, the ~8-flag soft cap. registry.go goes under CODEOWNERS with a named human team. + + +_Rationale: Every surviving field does mechanical work or gates review; the deleted five were form-filling tax (YAGNI-5), the exported-slice mutation leak (T-8) and the Stable-immortality hatch (LD-2) are closed by construction, and CODEOWNERS is the only real tooth for semantic classification in an agent-authored repo (LD-5, PV-5)._ + +### Flag value model and scope + +**Decision:** Two typed kinds only: rollout.Mode (Off|Auto|Require) for correctness/migration gates, *bool nil=default for kill-switches. City-global scope only. Honest claims replace overstated ones: the REGISTRY refuses per-agent scope (no scope field); the config system could still express one, so a reflection test fails if config.Agent/AgentPatch/AgentOverride ever gains a rollout.Mode-typed field, and the contributor doc states that a per-agent toggle changing what an agent may do is the forbidden shape regardless of declaration site. + + +_Rationale: PV-6: 'inexpressible by construction' was misleading — the honest version pairs the registry's refusal with the two mechanical checks that CAN exist plus a documented review rule._ + +### Config placement + fragment-merge preservation (BLOCKER 1) + +**Decision:** The flag field lives on the OWNING config section (BeadsConfig.ConditionalWrites beside BDCompatibility), read via a pure accessor mapping ""→default. MANDATORY per-field preservation branch in mergeFragment for every registry flag in a whole-table-LWW section, exactly mirroring the existing daemon.formula_v2 special case (verified at compose.go:1030-1047): a fragment defining an unrelated [beads] sibling key must NOT reset conditional_writes. Enforced by a HAND-WRITTEN merge regression test per flag (template: the daemon.formula_v2 pattern) — the generic registry-driven reflection merge harness is DELETED (no planned flag opens a new section; a one-sentence lifecycle-doc rule covers that future case). + + +_Rationale: As written the design shipped a silent require→off downgrade through routine fragment layering (PV-1 BLOCKER, verified in-tree). Y-9: the generic harness had zero consumers and known toml.MetaData subtleties; hand-written tests are the proven idiom._ + +### Load-time validation (no silent fallback via typo) + +**Decision:** config load (ValidateSemantics walk driven by registry ConfigPaths) rejects out-of-enum values for every registry flag with a fatal error naming field, bad value, and allowed set. Accessors only ever map ""; they never see an unvalidated non-empty value. The pre-existing NormalizedBDCompatibility silent-normalize (config.go:1401 default: case) is filed and fixed in the same PR. + + +_Rationale: OO-2: conditional_writes="requre" silently resolving to Off is a silent fallback that falsifies the design's central claim; the cited precedent is itself broken and must not be copied._ + +### Resolution precedence, Origin, and env semantics + +**Decision:** Precedence: builtin default → merged config (existing pack→city→fragment→patch chain, untouched) → env override → per-store runtime capability veto (can never raise, only veto) → structural test override. Origin is COLLAPSED to the three values recoverable with zero loader changes: builtin | config | env (per-layer provenance and the /v0/config/explain extension are deferred until someone asks, costed honestly as new compose.go plumbing then). Env grammar for Mode flags accepts ONLY the mode names (off|auto|require — no truthy spellings for tri-state); an unparseable env value on a correctness-category flag FAILS STARTUP FAST naming var, raw value, and grammar (a break-glass that silently no-ops at 2am is a failed break-glass); when a valid env override CONTRADICTS an explicitly-set config value, Resolve emits a startup structured log + typed event, not just a pull-surface Notice. Per-Spec EnvSemantics preserves each absorbed legacy flag's existing precedence (GC_DOLT_AUTO_GC_ENABLED stays fills-nil; unifying it later is an explicit release-noted breaking change, never a migration side effect). GC_BEADS_CONDITIONAL_WRITES is kept with its named consumer: hosted cities with baked/immutable config (crucible deployment model). Break-glass scope is documented as per-process; refusal/degrade diagnostics always carry mode+origin so cross-process divergence is visible in the first log line. Resolve's Notices are retained on the Flags value for the process lifetime and rendered by doctor/status. + + +_Rationale: Resolves Y-3 (Origin provenance doesn't exist to 'extend'), OO-4 (fail-open typo), PV-4/OO-7/Y-4 (env-wins retro-change and stale-var downgrade made push-loud and per-Spec), OO-8 (per-process divergence), OO-10 (notice lifetime), Y-12 (env override justified by hosted consumer)._ + +### Composition root and mode threading (single home) + +**Decision:** Resolve is folded into the shared loaders loadCityConfig/loadCityConfigWithBuiltinPacks so cfg and Flags travel as one value — resolution stops depending on per-command discipline across the ~30 config-load sites. The conditional-writes mode has EXACTLY ONE home: the beads factory (OpenStoreAtForCity/factory.go) stamps the resolved Mode onto every store it opens; ResolveConditionalWriter(store) takes NO mode parameter and reads the stamped mode — WithConditionalWrites as a caller-facing option is deleted, so the tested-but-unreachable store-says-Require/seam-says-Off state is inexpressible. Entry-point tests (controller, hook, sling, api server) assert that require in a temp city.toml is observed by a probe write. + + +_Rationale: T-1: cmd/gc has no run() choke point (verified: applyFeatureFlags call sites scattered incl. cmd_sling.go:247, cmd_agent.go:52); T-3: two homes let tests and prod diverge. Factory-stamping satisfies both threading-completeness and single-home; entry-point tests are the routeReadCmd lesson._ + +### Latching and hot-reload (BLOCKER 2) + +**Decision:** v1 of the subsystem is PROCESS-LATCHED for ALL flags; the Latch Spec field is deleted (YAGNI — no reload-tolerant flag exists yet). Operationally: controllerState retains the boot-resolved Flags; the reload path (cmd/gc/api_state.go:1808) carries that boot snapshot into ALL later-constructed components — it never hands a re-Resolved mode to new stores while old stores hold the boot mode. When on-disk config diverges from the latched value, a persistent 'pending restart: conditional_writes require (city.toml) != off (latched at start)' Notice is recorded, surfaced in doctor as a WARNING and later on the status wire. Regression test: boot Off, rewrite config to Require, trigger reload, construct a new store, assert it receives Off and the Notice fired. ResolveOptions (injected LookupEnv) threads into the reload seam. + + +_Rationale: OO-1 BLOCKER / T-2 / PV-7 / CC-5: the design text permitted a legacy writer racing a CAS writer on gc.control_epoch inside one process after a routine reload — the exact corruption the flag prevents. Whole-process latching is the only definition that makes 'epoch-fence semantics never flip mid-run' true, and YAGNI independently wanted the reload machinery gone._ + +### Capability model: per-store, one seam, precise classifier + +**Decision:** Capability is per RESOLVED store via the optional ConditionalWriter interface (ConditionalAssignmentReleaser template) with typed ErrConditionalWriteUnsupported and PreconditionFailedError{Expected,Current}. ONE injection seam: the capability probe runs through the store's existing CommandRunner (the bdReadyProjectionEnabled shape); WithBDCapabilityProbe is deleted so fake probe and fake runner can never contradict. The probe is LAZY (memoized on first conditional write, not store construction — no subprocess tax on every gc hook), greps the help of ALL FOUR verbs the consumers use (update/close/assign/delete — a mid-merge dev bd can support one but not another), and switches to ProbeBDVersion vs a deps.env bdConditionalWritesMinVersion anchor the day beads tags the release. Classifier: exit 9 → defensively parse the stdout JSON body into PreconditionFailedError; exit 13 latches capable=false ONLY when the machine-parseable body code equals conditional-write-unsupported — a bare 13 (e.g. the beads#3734 close-authority gate) surfaces as a typed NON-latching per-write refusal; usage/unknown-flag errors mentioning --if-revision (what pre-#4682 bd actually emits) map to ErrConditionalWriteUnsupported and trip the latch. Doctor renders probe verdict and runtime latch separately. Nothing persisted; restart re-probes (no-status-files). + + +_Rationale: CC-6 (policy-refusal conflation silently degrades every subsequent fenced write), CC-7 (old bd never emits 13 — the loud-degrade cell was unreachable), CC-10 (single-verb probe + construction-time cost), T-7 (duplicate seams test unreachable states)._ + +### CAS write semantics per consumer (fail-closed, no silent fallback) + +**Decision:** Four-cell matrix stands: off→byte-identical legacy; auto∧capable→CAS; auto∧incapable→legacy with once-per-store latched diagnostic + typed event; require∧incapable→typed refusal + store-open preflight + doctor ERROR. No code path converts ErrConditionalWriteUnsupported into a plain write. Consumer contracts are now EXPLICIT: (C6 drain reservation) exit 9 → re-read the key; current==control.ID → treat as success and proceed (self-win — preserves the existing three-outcome idempotent-re-entry contract at drain.go:1222-1246); current==other → skip. (C4 Attach epoch) CAS-LAST ordering is pinned; the exit-9 loser wires into the EXISTING partial-attach recovery (isPartialAttemptAttachError, molecule_failed stamping) — loser marks its just-created sub-DAG molecule_failed and neutralizes its dep edge; the level-triggered pass converges on the winner via findExistingAttach. (Ambiguity contract) ambiguous transport errors (isBdAmbiguousWriteError class) on writer-identifying values MUST self-win-check on re-read before concluding loss; the epoch increment tolerates a false loss ONLY because findExistingAttach idempotency runs before the fence — documented on the seam, tested by injecting an ambiguous error after a committed write via the fake CommandRunner. (Granularity) the interface documents that consumers may assume neither value-level nor revision-level conflict semantics; the BdStore read-revision→--if-revision emulation loop is BOUNDED (attempts+backoff) with a typed exhaustion error distinct from PreconditionFailed, and a bd-sql conditional-UPDATE value-CAS (the ReleaseIfCurrent template, bdstore.go:1097) is evaluated to sidestep cross-key interference on metadata-hot control beads. (CachingStore) EVICT, never patch: delete the cache entry on CAS-success-with-failed-refresh AND on every PreconditionFailed; the MemStore-backed CachingStore livelock regression test is a MERGE GATE of the ConditionalWriter PR. + + +_Rationale: CC-1/CC-2/CC-3 were correctness-eating: self-owned reservations read as losses (stranded undrainable members), unspecified Attach ordering wedges workflows via orphan sub-DAGs, and committed-but-ambiguous CAS writes convert self-wins into false losses. CC-8/CC-9 close the starvation and stale-revision-livelock modes._ + +### sqlite ConditionalWriter is a blocking deliverable + +**Decision:** The sqlite graph store's CompareAndSetMetadataKey (single conditional UPDATE) plus an integration test against the REAL deployed store shape (the deploy/sqlite-b36-probe-attribution topology holding gc.control_epoch / gc.drain.reserved_by) is promoted from a risks footnote to a BLOCKING deliverable of the C4/C6 PR. Until it lands: doctor renders the graph-class store's capability verdict specifically, and the runbook forbids require on the deployed topology. The design doc and runbook also state the fleet-scoped invariant: CAS mutual exclusion holds only when every writer to a ledger is CAS-active or exactly one writer exists; doctor warns when auto is DEGRADED under a declared multi-writer topology. + + +_Rationale: CC-4 (verified: no sqlite ConditionalWriter exists in-tree; the deployed controller is exactly where the fence matters — without this the flag is permanent DEGRADED where it was motivated, or fleet-stalling refusals) and CC-5 (mixed-writer honesty)._ + +### Test seams (all typed, all per-instance) + +**Decision:** (1) rollout.ForTest takes TYPED With* option funcs (rollout.WithBeadsConditionalWrites(rollout.Require)) generated alongside each Flags accessor — deleting a flag breaks tests at COMPILE time; the string-keyed unknown-key path does not exist. (2) Resolve takes injected LookupEnv (map-backed fake; no t.Setenv; GC_BEADS_CONDITIONAL_WRITES registered in LeakVectorVars, enforced by a registry test). (3) Capability-absent is an INSTANCE TOGGLE (mem.DisableConditionalWrites=true → methods return ErrConditionalWriteUnsupported, interface set intact) — the withoutConditionalWrites wrapper is deleted because it silently strips all five optional store interfaces (the class_store.go:15 lesson). (4) A store-agnostic ConditionalWriter CONFORMANCE SUITE (which operations bump revision, exit-9 equivalence, empty-expected semantics, monotonicity — documented as the interface's doc-comment contract) runs over MemStore, FileStore, CachingStore-over-MemStore, and sqlite in unit CI, and over BdStore against real bd under //go:build integration; it slots into the existing contract-test system (PR #3714). New internal/rollout test package ships its generated testenv_import_test.go. + + +_Rationale: T-9 (stringly ForTest), T-5 (wrapper erases sibling capabilities — already bitten in-tree), T-4 (fake revision-discipline divergence makes green CI predict nothing about production bd)._ + +### Lifecycle enforcement: deterministic teeth, no time bombs + +**Decision:** Merge-blocking CI checks must be DETERMINISTIC PER COMMIT — no bare date-vs-time.Now() anywhere in the Check path (this repo's agent fleet treats red as a stall; the trivyignore cliff is the prior art). Two-stage version-anchored graduation as ONE plain Go test in the TestBDVersionPins family (~20 lines, NO predicate DSL): stage 1 — deps.env BD_VERSION >= bdConditionalWritesMinVersion && default still Off ⇒ fail demanding the Off→Auto flip; stage 2 — BD_PREV_VERSION >= floor && flag still registered ⇒ fail demanding DELETION (flag, accessor, config field, dead legacy branches); GraduatedIn is recorded in the Spec at flip time. The firing test offers a bounded diff-visible deferral: the version-bump PR may set a machine-checked FlipDueBy = current anchor + 1 bump, so a CVE-driven bd bump never forces a rush Require flip in the same PR — silent-forever stays impossible. Wall-clock Expires moves ENTIRELY to a scheduled non-blocking nightly radar that files/updates a bead against the Owner and feeds a doctor WARN; expiry only hard-fails PR CI when registry.go itself is in the diff. Per-category rules in registry_test: infra-rollout|infra-migration may NEVER be immortal — mandatory Expires + version anchor, terminal state is deletion; only infra-killswitch may be long-lived. The soft cap is DELETED. Tombstones (RetiredKeys in undecoded.go) are minted with RemovedIn= and flagged for deletion by the radar once the anchor exceeds RemovedIn+1 — no wall clock, no 'one release' ambiguity in a branch-deployed fleet. Owner is dual (bead + GitHub handle) and registry.go is CODEOWNERS-gated. + + +_Rationale: LD-1 BLOCKER + T-6 + Y-2 (zero-diff red = fleet-wide stall + trained neutering), LD-3 (BD_PREV_VERSION historically doesn't move — verified still v1.0.4 vs the 1.0.5 ready-projection floor — and nothing checked the terminal state), LD-6 (bump-PR blast radius), LD-2 (Stable hatch), Y-6/Y-7 (DSL and cap were N=0 machinery), LD-5/LD-9 (orphan owners, undefined release boundaries)._ + +### Two consumers at stage 1 + legacy-mechanism freeze + +**Decision:** The registry ships in stage 1 with TWO Specs registered on day one: beads CAS (infra-rollout) AND formula_v2 (infra-migration, Owner + version-anchored expiry) — the abstraction is born describing two real consumers even though formula_v2's code migrates in stage 5. Stage 1 also lands the FREEZE: a golden-list boundary test (the TestGCNonTestFilesStayOnWorkerBoundary shape) failing on any NEW call site of SetFormulaV2Enabled/SetGraphApplyEnabled/applyFeatureFlags/syncFeatureFlags beyond the current inventory, plus a frozen-baseline inventory test failing on any NEW os.Getenv/LookupEnv site matching "GC_" outside testenv gates, registry EnvOverrides, and an enumerated checked-in baseline — a shadow flag now requires a loud, reviewed baseline edit. The formula_v2 code migration (deleting cmd/gc/feature_flags.go, syncFeatureFlags, both atomic.Bools, the formulatest mutex, ~20 save/restores) is a COMMITTED blocking bead in the same milestone, and its slippage trips the registered Spec's own lifecycle teeth. A tombstone obligation for the deprecated graph_workflows alias is registered in the same pass. Reverse parity is claimed only where mechanically definable: any field typed rollout.Mode (in City OR Agent/AgentPatch/AgentOverride) must have a Spec / must not exist respectively; *bool classification is honestly review-governed. + + +_Rationale: Y-1 (N=1 abstraction vs the repo's two-implementations rule) and LD-4 (nothing forced stage 5; the old mechanism keeps recruiting) resolve each other: registering both consumers first makes the registry N=2 in contract, the freeze makes the old pattern un-copyable, and the Spec's own expiry makes stage-5 slippage self-punishing. PV-3: the bypass had to become mechanically more expensive than the sanctioned path._ + +### Observability: minimal in slice 1, honest, push-based for degrade + +**Decision:** Slice 1 ships gc doctor ONLY: registry-rendered Rollout Flags section (resolved mode, Origin builtin|config|env, Owner, per-store capability verdicts with probe-vs-latch shown separately, ACTIVE/DEGRADED/FAIL-CLOSED/pending-restart) — ALWAYS with an explicit banner when resolving locally: 'city not running — values resolved from this shell's env and PATH and may differ from the daemon'. Doctor exit contract is pinned by test: FAIL-CLOSED (require∧incapable) and radar-surfaced past-due items render as ERRORS with nonzero exit; DEGRADED is a warning with exit 0. Stage 2/3 add the PUSH surface: a typed registered event beads.conditional_writes.degraded {store, mode, reason, bd_version}, latched once per store — DEGRADED shows in event history and is alertable instead of depending on someone running doctor. The status-wire type — an aggregate verdict PLUS a typed per-store array {store_id, kind, capable, reason} (one boolean cannot express a mixed fleet) including origin and retained Notices — rides the C2/go.mod-bump PR, which already forces genspec + three spec copies + dashboard TS for Bead.Revision; once it exists, doctor queries the live API when the city is up and renders the daemon's OWN latched snapshot. The /v0/config/explain extension is deferred with per-layer origin. + + +_Rationale: Y-8 (four surfaces for a default-off experimental flag bloats the correctness PR), OO-3 (doctor as re-derivation can lie in both directions — live-API is the fix, staged where the wire regen is free), OO-5 (triple can't carry per-store), OO-6 (the most-likely-to-persist state emitted nothing pushable), OO-9 (exit-code contract)._ + +### Principle line: how the capability-flag exclusion is actually enforced + +**Decision:** The line stands — the exclusion bans agent-behavior toggles that smarter models obviate; infra rollout gates select between two mechanical transports invisible to prompts — but enforcement claims are made honest. Structural (CI): closed Category enum with no agent-capability member; no scope field on Spec; the Agent-struct reflection guard; prompt rendering EXTRACTED from package main into internal/prompt (small mechanical move that also fixes the rendering-in-CLI layering smell) so the forbidden import edge internal/prompt→internal/rollout actually exists and is testable; a registry-driven AST lint (the TestNoLeakVectorReadsAtPackageInit precedent) asserting no PromptContext construction, no PromptContext.Env write, and no template FuncMap references any rollout.Flags accessor. Review-governed (stated as such, not oversold): the value-flow half — a flag value laundered through a bare bool into template data — is caught by the SelectsBetween articulation, the litmus questions in the registry file header ('would a 10x-smarter model obviate this?' / 'do both branches move bytes rather than make decisions?'), the PR-template checklist item ('does any template data struct field trace to a rollout flag?'), and the CODEOWNERS human gate on registry.go. The design text says explicitly: the semantic line is enforced by review-with-teeth; CI blocks the naive paths. + + +_Rationale: PV-2 (the import test as originally claimed was unimplementable — rendering lives in the same package as the composition root — and PromptContext.Env leaks values without any import edge), PV-5 (form vs semantics), Y-10/Y-11 (overclaiming is how checks get cargo-culted then neutered)._ + +### Rejected findings + +**Decision:** TWO rejections. (1) OO-8's 'consider restricting EnvOverride on process-latched correctness flags to the daemon entry point only' — REJECTED as a v1 mechanism: it complicates the resolver with entry-point awareness for a divergence that the adopted fixes (documented per-process scope + origin-tagged refusal/degrade diagnostics + env-contradicts-config startup event) already make visible in the first log line; revisit if a real bifurcated incident occurs. (2) PV-4's 'require force-off spelling for downgrades' — REJECTED as separate grammar: superseded by the stricter adopted rule that Mode-flag env vars accept ONLY the literal mode names and anything else fails startup on correctness flags; a typo'd truthy value can therefore never downgrade require silently, which was the scenario's teeth. + + +_Rationale: Both were 'consider' suggestions whose threat is fully covered by adopted amendments with less mechanism._ + +## Red-team verdicts (all folded into the decisions above) + +- **principle-violation**: PROCEED_WITH_AMENDMENTS (1 blocker(s)) +- **testability**: PROCEED_WITH_AMENDMENTS (0 blocker(s)) +- **cas-correctness**: PROCEED_WITH_AMENDMENTS (0 blocker(s)) +- **lifecycle-debt**: PROCEED_WITH_AMENDMENTS (1 blocker(s)) +- **operability-observability**: PROCEED_WITH_AMENDMENTS (1 blocker(s)) +- **yagni-scope**: PROCEED_WITH_AMENDMENTS (0 blocker(s)) + +## Decisions locked (2026-07-09 review) + +Three build-shaping questions were decided; the design above is authoritative and these override any contrary phrasing in it: + +- **Scope = FULL REGISTRY NOW.** Stage 1 ships `internal/rollout` + the typed registry with BOTH the CAS gate and `formula_v2` registered, and the `formula_v2` code migration lands as a **blocking same-milestone** bead (satisfies the "two implementations" rule for real, not on paper). +- **Break-glass on a malformed `GC_BEADS_CONDITIONAL_WRITES` = WARN AND USE CONFIG.** Do not refuse to start. Log a loud warning, ignore the malformed override, fall back to the config-declared mode, and keep the notice on the status wire. (Availability over strict mode-correctness on a mistyped break-glass.) +- **Require mode + mixed-writer topology = `gc doctor` ERROR (block).** When config declares `Require` on a multi-writer topology containing any non-CAS-capable writer, doctor hard-errors — the fleet-scoped invariant cannot hold, so refuse to let the operator believe it does. (Not merely a warning.) +- **`Auto`/capability-resolution is a GENERAL mechanism, NOT beads-locked.** The tri-state `Mode` and the capability-resolution machinery are subsystem-level: a flag opts into `Auto` by supplying a general `rollout.Capability` predicate (`func(ctx) (capable bool, reason string)` — or a small interface), and the resolver computes `enable ∧ capable` generically. beads CAS is consumer #1 and supplies a bd/store capability predicate; a future non-beads flag can supply its own. `ResolveConditionalWriter(store, mode)` is CAS's thin, consumer-owned adapter over the general resolver — NOT the general API. The general core (registry, `Mode`, resolve(enable, capabilityPredicate) → effective) lives in `internal/rollout` with zero beads imports; an import-boundary test forbids `internal/rollout` from importing `internal/beads`. Flags with no runtime capability question (e.g. `formula_v2`) simply supply no predicate and use `Off`/`Require` (≡ off/on). + +## Open questions still to resolve (during stage-1 planning; not build-blocking) + +3. **Deployed-topology test ownership:** the sqlite `ConditionalWriter` integration test must run against the `deploy/sqlite-b36-probe-attribution` store shape maintainer-city actually runs — port that harness into main's fixtures, or stage a deploy-branch test as the stage-3 gate? (Owner TBD.) +4. **Named humans / CODEOWNERS:** dual `Owner` (bead ID + GitHub handle/team) per flag, and a `CODEOWNERS` entry for `internal/rollout/registry.go` (the only human gate on `Expires` extensions + `Category`). Owning handle for the CAS flag and for `formula_v2`? (Default: Julian, unless delegated.) +5. **`internal/prompt` extraction (PR-1a):** moves prompt rendering out of `cmd/gc` package main to make the prompt-boundary import test real. Do it in this milestone, or defer and rely on the AST lint + review checklist in v1? (Leaning: do it, since "full registry now" wants the structural enforcement real.) +6. **Graduation-pace forcing function:** stage-2 default-flip/deletion fires when `BD_PREV_VERSION` crosses the CAS floor, but that anchor has historically not moved. Add a radar that flags `BD_PREV_VERSION` lagging `BD_VERSION` by >2 releases, or accept indefinite `Auto`-with-legacy-branches once the default has flipped? diff --git a/engdocs/plans/feature-flags/EXECUTION-PLAN.md b/engdocs/plans/feature-flags/EXECUTION-PLAN.md new file mode 100644 index 0000000000..81910a492b --- /dev/null +++ b/engdocs/plans/feature-flags/EXECUTION-PLAN.md @@ -0,0 +1,915 @@ +# internal/rollout — Full Execution Plan (all phases, hardened) + +_Fable-authored plan, gap-analyzed by 6 Fable lenses, hardened by a Fable pass. Stage-1 planner and cross-cutting planner stalled on oversized output; the harden pass reconstructed the full Stage-1 breakdown from the gap findings. See DESIGN.md for the subsystem design; STAGE1-CODEMAP.md for exact code touchpoints._ + +## Verdict + +BUILDABLE AS AMENDED. The proposed S2–S5 plan is structurally sound but was unbuildable as delivered: Stage 1 was entirely absent while ~40 downstream ACs consumed named S1 artifacts, Bead.Revision and sqlite_store_conditional.go were each owned by two stages with contradictory ACs, degraded-event emission was built twice two different ways, the deploy lineage (220 commits behind main, where the fence actually executes) had no sync task and no mode-stamp path, and the general-Auto acceptance condition had zero non-beads Auto coverage. All 6 BLOCKERs and every HIGH are resolved below by: (1) authoring S1 as four independently mergeable PRs with an exact TDD order; (2) single-owner rules — revision field lands once in S4, sqlite writer lands once in S3.3, event emission lands once via the factory callback defined in S2/registered-only and wired in S3.6; (3) an explicit deploy-lineage sync task (S3.0b) before any deploy-side work; (4) an exported general rollout.ResolveCapability with a synthetic non-beads capability test and grep-guard teeth making the thin-adapter claim structurally true. The plan is now executable in the stated PR order with no dangling artifact references. + +## PR / merge sequence + +ORDERED PR/MERGE SEQUENCE (targets gascity origin/main unless noted; ∥ = may run in parallel): + +1. PR-1a — internal/prompt extraction (S1-T14). Fully parallel, non-blocking; nothing in S2–S5 depends on it. Prompt-boundary import test lands with whichever of PR-1a/PR-1b merges second. +2. PR-1b — internal/rollout package (Spec/registry/Mode/ResolveCapability/Origin/env break-glass/ForTest) + internal/config field, IsDefined merge branch, merge-regression trio + two day-one Specs + CODEOWNERS + allowlist import-boundary test. ZERO production wiring — flag inert, zero behavior change. ∥ with PR-S2a. +3. PR-S2a — S2-T1..T8 (interface, typed errors, conformance harness + openIncapable factories + reflection exhaustiveness, Mem/File, classifier, probe/latch, BdStore verbs + CAS retry, emulation loop + spike, CachingStore evict-never-patch). No S1 dependency (deliberate); no json-visible Revision on Bead (revision is store-internal). Mergeable before or alongside PR-1b/1c. S2-T9 is DELETED (folded into S3.3). +4. PR-1c — composition-root wiring: State.RolloutFlags() vehicle, Resolve wired BESIDE applyFeatureFlags/syncFeatureFlags (legacy untouched until S5-T5), boot-latch + pending-restart notice, doctor Rollout Flags section + EffectiveStatus aggregator in rollout, entry-point tests to the seam. Depends: PR-1b. +5. PR-1d — lifecycle/freeze teeth: repo-root-walk frozen legacy inventory with per-package test ceilings, GC_* env-read baseline (non-test files only, stated), dormant TestConditionalWritesGraduation + registry.go-diff-gated expiry check (no wall-clock in merge CI), undecoded.go RetiredKey mechanism, radar DEFERRED by owner bead. Depends: PR-1c. +6. PR-S2b — S2-T10 (factory mode-stamp via ModeStamped optional interface + ResolveConditionalWriter thin adapter + bypass inventory + unstamped→Off test + Require∧capable and Unset cells), S2-T11 (event REGISTRATION only, full field set incl. origin/store_kind, + spec-regen commit via make spec-ci), S2-T12 (integration row via contract-system binary pinning, exit sweep). Depends: PR-1c, PR-S2a. deps corrected to T6,T7,T8,T10,T11. +7. S3.0b — deploy-lineage sync: merge origin/main (carrying S1+S2 merged) into deploy/sqlite-b36-probe-attribution (or cut fresh deploy branch off main and re-apply the sqlite commit stack); run S1/S2 suites green on the synced lineage. BLOCKS S3.3/S3.4a/S3.5. All "applies clean" ACs re-anchor to the SYNCED lineage. +8. PR-S3-main — S3.0 preflight + spike verdict note, S3.1 (C6 incl. release CAS), S3.2 (C4 incl. advanceAttachEpochIfNeeded conversion, findExistingAttach molecule_failed continue-guard, crash-mid-cleanup test, exhaustion-as-transient), S3.4b (beadPolicyStore + stamp forwarding), S3.6 (emission wiring at both roots, doctor rows, multi-writer predicate + require+multi-writer ERROR cell), S3.7 runbook. +9. PR-S3-deploy — on the SYNCED lineage: S3.3 (sole author of sqlite_store_conditional.go), S3.4a (lazy/noClose forwarding + coordrouter.Router ConditionalWriter shim + OpenSQLiteStore mode-stamping at registerGraphStoreBackend/lazy-heal/handle-cache), S3.5 (5-leg blocking integration test + configured-mode assertion) + S3.5b (named CI venue: deploy-branch workflow running the integration shard), + cherry-pick of PR-S3-main. S3.8 checklist gates both PRs jointly. +10. S3.8 post-merge — flip maintainer-city to conditional_writes=auto; open the dated soak-tracking bead (>=1 week, zero graph-store degraded events, doctor graph=capable). This bead ID is a mechanical dependency of step 14. +11. PR-A (S4-T0 gate → T1..T4) — HARD-GATED on a tagged beads release carrying #4682, plus S4-T0b schema-delta/rollback assessment. bd pin + SHA table + workflow pins; BD_CONDITIONAL_WRITES_MIN_VERSION anchor (flips Spec VersionAnchor pending→concrete); probe transition to version-compare (with rc/dev/suffixed rows); FlipDueBy= set, NO default flip. +12. PR-B (S4-T5..T11) — Revision on Bead (single owner) + store plumbing; go.mod bump; regen #1; C2 handlers incl. If-Match loud-rejection guard on non-CAS-wired mutations; status wire consuming rollout.AggregateEffective; regen #2; dashboard TS restore + drift gate (ga-iialk6); events cross-version fixture decode test; close-out gates + C2 revert contract in runbook. Independently mergeable vs PR-A (verified by old-bd CI run). S4-T5b: deploy-side port of the revision extension rides the next lineage sync, not PR-B. +13. S4-T12 flip PR — deps: S4-T11 AND S3.8-soak-bead-closed (mechanical, not prose). Registry data edit only; emergency un-flip = city.toml override + restart, documented. +14. S5 (T1 → T2 → T3 → T4 → T5 → T6∥T7 → T8) — S5-T2 BLOCKED on PR-S3-main merged (drain.go/molecule.go stability); S5-T5 additionally gated on "no open feature branch imports internal/formulatest" checklist sweep; S5-T3 executed via worktree-isolated subagents, landed fast. + +External gate: step 11+ slips with the beads release tag; steps 1–10 are fully executable today. + +## General-`Auto` compliance (user acceptance condition — capability-resolution is GENERAL, not beads-locked) + +CONCRETE CHECKLIST — every item is a named, mechanically checkable artifact proving Auto/capability-resolution is GENERAL, not beads-locked: + +1. ALLOWLIST boundary test (S1-T1, re-run as an AC at S2-T12, S4-T11, S5-T8): internal/rollout may import ONLY stdlib + internal/config (+ internal/deps if version helpers needed); ANY other package — including beads-ADJACENT ones (internal/beadmeta, internal/dispatch, internal/molecule, internal/events) — fails the test naming the package. Red-tested in S1 with synthetic beads AND beadmeta imports. +2. EXPORTED general resolver (S1-T3): rollout.ResolveCapability(mode Mode, cap Capability) Decision, Decision ∈ {UseLegacy, UseNew, DegradeLoud, RefuseClosed} + reason string; rollout.Capability = func(ctx) (capable bool, reason string). The enable∧capable cell product is computed HERE and nowhere else. +3. SYNTHETIC NON-BEADS Auto test (S1-T3, internal/rollout/capability_test.go): a fake "runtime provider supports nudge" closure drives ALL cells — Off/Auto/Require × capable/incapable, nil-predicate, ModeUnset — asserting verdict + reason propagation, using only rollout types. This is the artifact the user's acceptance condition asks for; S2/S3 GENERAL_AUTO exit lines reference this test by name instead of narrative claims. +4. THIN-ADAPTER TEETH (S2-T10 AC): grep/AST guard asserting no comparison against rollout.ModeAuto/ModeRequire exists in internal/beads outside the factory stamp; beads.ResolveConditionalWriter contains NO mode cell-branching — it only supplies the beads predicate (unwrapped-store interface assert + probe/latch) and maps the returned rollout.Decision onto (writer | nil+Diag | typed error). Blocks any future in-package re-implementation of the product. +5. SPEC IS PURE DATA (S1-T2 test): no func-valued fields on Spec; capability predicates are supplied per-call by consumer adapters, never stored on Spec or in any global registry — resolving the S5-T1/S2-T10 contradiction, keeping registry.go CODEOWNERS-reviewable and the S4-T4/T12 graduation edits data-only. +6. FULL MATRIX everywhere (S2-T10, S3.1, S3.2): the canonical template is 3 modes × 2 capability + ModeUnset (Off + recorded BeadsDiagnostic "mode not threaded"), incl. Require∧capable and Require + mid-run latch-trip; every "four-cell" AC string updated to the complete matrix. +7. PREDICATE-LESS PROOF (S5-T1): daemon.formula_v2 (non-beads, *bool, no EnvOverride, no predicate) resolves with the capability leg provably vacuous — Auto/capability is strictly opt-in per flag. +8. SHARED STATUS VOCABULARY (S1-T9/S3.6/S4-T9): EffectiveStatus enum + worst-of aggregator (fail_closed > degraded > pending_restart > active > off) defined ONCE in internal/rollout (pure data + pure function, boundary-safe); doctor and the status wire both consume rollout.AggregateEffective — no second truth table; the overreaching "rows for free" sentence struck from S3's claims. +9. ZERO ROLLOUT DELTAS IN CONSUMER STAGES: S3 adds no lines to internal/rollout; S4 touches it in exactly two data-only registry edits (FlipDueBy, Default flip); verified by diff inspection at each stage gate. +10. END-STATE CENSUS (S5-T8): registry holds 4 consumers — beads CAS (Auto, per-call predicate), daemon.formula_v2 (predicate-less migration), dolt.auto_gc_enabled + events.rotation.enabled (killswitches with divergent EnvSemantics) — 3 of 4 non-beads; the boundary test passed throughout without edits. A future non-beads Auto consumer needs only its own predicate + Spec, demonstrated executable by artifact #3. + +## Stage 1 — internal/rollout foundation (TDD task order) + +- S1-T1 [PR-1b] Import-boundary test FIRST (allowlist form: stdlib + internal/config [+ internal/deps] only; fails naming the package) — written red against a synthetic internal/beads AND internal/beadmeta import in a scratch internal/rollout skeleton, then the empty package lands green +- S1-T2 [PR-1b] Spec type + registry: registry_test.go red-first for shape/completeness rules — per-category (Expires+VersionAnchor mandatory infra-rollout/migration, forbidden killswitch), EnvOverride uniqueness, reflection-verified ConfigPath vs City toml tags, test-failure-not-panic registration, Spec-is-pure-data (no func fields), and the VersionAnchor 'pending' state (anchor names the deps.env key; key-absent = explicit pending, distinct from missing/empty which stays a failure) — then implement +- S1-T3 [PR-1b] Tri-state Mode + ModeUnset + the GENERAL resolver: capability_test.go red-first driving exported rollout.ResolveCapability(mode, cap) Decision{UseLegacy,UseNew,DegradeLoud,RefuseClosed}+reason with a SYNTHETIC non-beads predicate through all cells (3×2, nil-predicate vacuous, ModeUnset→Off+diagnostic) — the general-Auto acceptance artifact — then implement +- S1-T4 [PR-1b] Origin tracking + precedence + env break-glass: resolve_test.go red-first with injected LookupEnv (never t.Setenv) — config/env/default precedence with typed Origin, malformed env = WARN-AND-USE-CONFIG + env_overrides_config Notice (never refuse-to-start), pending_restart Notice type, shared bool grammar (ParseBool ∪ on/off ∪ y/yes ∪ enabled/disabled, case/space-tolerant) — then implement +- S1-T5 [PR-1b] rollout.ForTest per-instance DI seam + typed Flags accessors: two t.Parallel tests with opposite WithX values assert isolation (zero process-scoped mutable state) — red until the per-instance Flags value exists +- S1-T6 [PR-1b] Config field + fragment-merge preservation: config_test red-first for BeadsConfig.ConditionalWrites as a PLAIN VALIDATED STRING (mapping to rollout.Mode lives in rollout — resolves the DESIGN line 507 import cycle; errata note added to DESIGN §4.1); compose_test merge-regression trio proven RED against today's whole-table [beads] LWW (compose.go:1030), then the per-field IsDefined branch (template: the daemon clause at :1042); registry default-equality test (zero-value config == Spec.Default) in rollout +- S1-T7 [PR-1b] Register the two day-one Specs: beads.conditional_writes (infra-rollout, VersionAnchor=pending on BD_CONDITIONAL_WRITES_MIN_VERSION, Owner bead) and daemon.formula_v2 (infra-migration, existing ConfigPath, no EnvOverride, Default true); CODEOWNERS line on internal/rollout/registry.go. PR-1b merges: package + config, INERT, zero production wiring +- S1-T8 [PR-1c] Composition-root wiring: State interface gains RolloutFlags() rollout.Flags (controllerState stores it at boot beside cfg; every fake updated — cost named in files list); Resolve wired at the cmd/gc loaders (cmd_agent.go:39/61, cmd_start.go:989, cmd_sling.go:433, cmd_config.go:25) and api New/NewReadOnly — ADDED BESIDE applyFeatureFlags/syncFeatureFlags, which stay byte-untouched until S5-T5 (AC + freeze inventory enforce); boot-latch + pending-restart Notice on both reload paths (api_state.go:1808, controller.go:923 keep their legacy re-apply behavior); entry-point tests red-first: temp city.toml require → Mode=Require+Origin=config observed at each root; server-over-changed-on-disk-config still serves the boot snapshot; bd-wire probe leg deferred BY NAME to S3 +- S1-T9 [PR-1c] Doctor Rollout Flags section (cmd/gc/doctor_rollout.go): renders every registered Spec with value+origin+notices; EffectiveStatus enum + worst-of aggregator implemented in internal/rollout (shared by S3.6 doctor and S4-T9 status wire); exit contract red-first: fail_closed→ERROR nonzero, degraded→WARNING zero. PR-1c merges +- S1-T10 [PR-1d] Legacy freeze tooth: REPO-ROOT walk (not cmd/gc-only — the api twin at server.go:197/203/229 and the formula/molecule setter defs are in scope) with a checked-in inventory keyed by repo-relative path, explicit counting rule (non-test .go, comments excluded — the feature_flags.go:9 doc-comment trap), per-package test-reference COUNT CEILINGS (formula 54, molecule 44, dispatch 8, cmd/gc 5, graphroute 4, api 4, bootstrap 2), needles = 4 symbols + 4 formulatest wrapper names — red-tested by adding a scratch call site +- S1-T11 [PR-1d] GC_* env-read frozen baseline: golden inventory of the ~231 non-test read sites; test doc states explicitly it covers NON-TEST files only (pre-resolving the S2-T12 conflict); red-tested with a scratch os.Getenv("GC_X") +- S1-T12 [PR-1d] Graduation + expiry teeth: TestConditionalWritesGraduation reading BD_CONDITIONAL_WRITES_MIN_VERSION via readDotenv, dormant-when-absent and armed-when-present both proven on synthetic dotenv fixtures; expiry check fires ONLY when registry.go is in the PR diff — NO time.Now() in merge-blocking CI (corrects the plan header; wall-clock staleness = doctor WARN only); scripts/rolloutradar DEFERRED with a named owner bead — doctor WARN on FlipDueBy-pending is the interim surface and S4-T4's AC is rewritten against it +- S1-T13 [PR-1d] internal/config/undecoded.go RetiredKey mechanism: retiredKeys table, warning-not-fatal downgrade, RemovedIn as version anchor, unknown-key fatality unchanged — red-first with a synthetic retired key. PR-1d merges; S1 exit gate: flag resolves everywhere, nothing consumes it, zero behavior change, all four PRs green alone +- S1-T14 [PR-1a, PARALLEL/non-blocking] internal/prompt extraction: move cmd/gc/prompt.go verbatim, export PromptContext/Render/BuildTemplateData, inject the session-name resolver as a param (impl stays cmd/gc-side), convert 3 construction sites + cmd_lint's inline path (~6 files); prompt-boundary import test lands with whichever of PR-1a/PR-1b merges second; recorded fallback if the milestone runs long = AST lint + review checklist per DESIGN open question 5 + +## S2-conditionalwriter — Land the full ConditionalWriter machinery in gascity's internal/beads with zero consumers: the optional store interface + typed errors + internal Bead.Revision, the BdStore exit-9/exit-13 classifier and lazy four-verb capability probe, a dedicated CAS retry policy (never the blind transient loop), bounded metadata-CAS emulation, native Mem/File implementations with instance capability toggles, CachingStore forward-and-EVICT, the sqlite ConditionalWriter (new-file-only, deploy-lineage staged), the factory mode-stamp + ResolveConditionalWriter(store) thin adapter over the general rollout resolver, the beads.conditional_writes.degraded typed event, and a store-agnostic conformance suite (unit CI over Mem/File/Caching/sqlite; BdStore vs real bd under //go:build integration). Everything mode-blind at store level; no code path anywhere converts ErrConditionalWriteUnsupported into an unconditional write. + +### [S2-T1] ConditionalWriter interface, typed errors, and internal Bead.Revision (TDD: contract doc + error-identity tests first) +In /data/projects/gascity/.claude/worktrees/reconciler/internal/beads/beads.go: (a) add `Revision int64 `json:"revision,omitempty"`` to Bead — INTERNAL only; the HTTP-API wire change (C2, OpenAPI/TS) is Stage 4; BdStore populates it from bd JSON when present (pre-#4682 bd omits it → 0). (b) Declare `ConditionalWriter` (UpdateIssueIfMatch(id string, expectedRevision int64, opts UpdateOpts) error; CloseIssueIfMatch; DeleteIssueIfMatch; CompareAndSetMetadataKey(id, key, expected, next string) (bool, error)) modeled on ConditionalAssignmentReleaser (beads.go:115), with the NORMATIVE doc comment carrying the revision contract (every mutation bumps; reads never; monotonic, never reused; opaque equality-only) and the granularity contract (consumers may assume NEITHER value-level nor revision-level conflict semantics). (c) Typed errors beside existing sentinels: `ErrConditionalWriteUnsupported`, `PreconditionFailedError{ID, Expected, Current int64, Raw string}`, `GateRefusalError{ID, Verb, Code, Raw}`, `CASRetriesExhaustedError{ID, Key string; Attempts int; LastRevision int64}` — DESIGN.md §8.1 verbatim semantics. Write error tests first: errors.As/Is behavior, Error() strings include mode-relevant context, exhaustion is distinct from precondition-failed and from (false,nil). + +- **files:** internal/beads/beads.go, internal/beads/beads_test.go +- **acceptance:** + - ConditionalWriter doc comment contains the full revision + granularity contract text (it is the contract the conformance suite executes) + - All four error types exported with doc comments; CASRetriesExhaustedError is NOT an errors.Is match for PreconditionFailedError + - Bead.Revision decodes from bd-style JSON with a `revision` key and is 0 when absent (corpus_decoder_test.go / event_payload_contract_test.go stay green or are updated in the same commit) + - No wire/OpenAPI surface changes: internal/api/openapi.json untouched; make dashboard-check not triggered + - go vet ./internal/beads/ clean; every exported symbol documented +- **tests:** + - internal/beads/beads_test.go: TestConditionalWriterErrorIdentity (As/Is matrix over the four types) + - internal/beads/beads_test.go: TestBeadRevisionDecodesFromBDJSON (present, absent, non-numeric-tolerant per StringMap precedent decisions) + - existing corpus_decoder_test.go re-run to prove decode tolerance + +### [S2-T2] Store-agnostic ConditionalWriter conformance harness in beadstest (written first, red until implementations land) +Add `RunConditionalWriterConformance(t *testing.T, open func(t *testing.T) beads.Store)` to /data/projects/gascity/.claude/worktrees/reconciler/internal/beads/beadstest/ (new file conditional_writer_conformance.go, following the existing RunStoreTests pattern in beadstest/conformance.go). Subtests per DESIGN §8.6/§7.5: every_mutation_bumps_revision (full verb matrix: update, labels, metadata, assign, close, reopen, CompareAndSetMetadataKey itself); reads_never_bump; revision_monotonic_never_reused; stale_revision_is_precondition_failed (typed, Expected/Current populated where the backend can supply them); cas_empty_expected_claims_absent_or_empty_only; cas_value_mismatch_is_false_nil_not_error; cas_winner_value_visible_to_loser_reread; contention (two goroutines race one key — exactly one true); disable_toggle_returns_typed_unsupported_with_interfaces_intact. Cases must exercise ONLY the caller-visible result surface (no interference-timing assertions — the granularity contract says those are undefined, so BdStore emulation and sqlite value-CAS both pass the same table). Capability-absent-by-interface is tested with a purpose-built minimal store type in the test file, never a wrapper (§7.3 ban). + +- **files:** internal/beads/beadstest/conditional_writer_conformance.go +- **acceptance:** + - Harness compiles against only the beads.Store + ConditionalWriter surface; zero store-specific branches + - Suite table mirrors the interface doc comment one-to-one (reviewer can diff them) + - No subtest asserts cross-key interference behavior (BdStore emulation spurious-retry internals invisible) + - Skip mechanism (if any store needs one) goes through the existing conformance_skips.go ledger — no bare t.Skip +- **tests:** + - The harness IS the test artifact; verified red-first by wiring MemStore before S2-T3 lands (expected failures listed in PR description) +- **depends_on:** S2-T1 + +### [S2-T3] MemStore and FileStore native implementations + DisableConditionalWrites instance toggles +Implement ConditionalWriter natively on MemStore (/data/projects/gascity/.claude/worktrees/reconciler/internal/beads/memstore.go — struct at :15, ReleaseIfCurrent template at :171) and FileStore (filestore.go, ReleaseIfCurrent at :232). Every existing mutation path (Create, Update, SetMetadata/SetMetadataBatch, Close, assign/claim, label edits, Tx surface writes) bumps Revision — audit ALL write methods, not just the new ones, or the conformance verb matrix fails. Add exported `DisableConditionalWrites bool` field to both: when true, all four methods return ErrConditionalWriteUnsupported while the interface set (ConditionalAssignmentReleaser, AtomicTxStore, etc.) stays intact — §7.3, no interface-stripping wrapper. Add `var _ ConditionalWriter = (*MemStore)(nil)` / `(*FileStore)(nil)` compile asserts. FileStore writes stay atomic (temp file → os.Rename) with revision persisted in the JSON. TDD: wire both into RunConditionalWriterConformance first, watch red, implement to green. + +- **files:** internal/beads/memstore.go, internal/beads/filestore.go, internal/beads/memstore_test.go, internal/beads/filestore_test.go +- **acceptance:** + - RunConditionalWriterConformance green over MemStore and FileStore in unit CI + - DisableConditionalWrites=true store still satisfies all other optional interfaces (asserted in the disable_toggle conformance row) + - CompareAndSetMetadataKey expected=="" matches absent OR empty-value key (release paths write "" to clear) + - Existing memstore_test.go / filestore_test.go / RunStoreTests suites stay green (revision bump does not perturb Store semantics) +- **tests:** + - memstore_test.go + filestore_test.go: TestMemStoreConditionalWriterConformance / TestFileStoreConditionalWriterConformance calling the harness + - filestore_test.go: revision survives store close/reopen (persistence leg Mem cannot cover) +- **depends_on:** S2-T1, S2-T2 + +### [S2-T4] BdStore exit-9/exit-13 classifier as a pure function + scripted CommandRunner fake +New file internal/beads/bdstore_conditional.go: `classifyConditionalWriteResult(out []byte, err error) error` — pure over the (out, err) the existing runner path already returns (classifyBDExecResult at bdstore.go:179 hands back out even on failure). Table per DESIGN §8.2: exit 9 + parseable JSON body {code, expected_revision, current_revision} → *PreconditionFailedError{Expected, Current}; exit 9 + unparseable → zero-valued PreconditionFailedError with Raw (defensive extractJSON idiom, bdstore.go:485, tolerates surrounding noise); exit 13 + body code=="conditional-write-unsupported" → ErrConditionalWriteUnsupported (LATCHES); exit 13 otherwise (beads#3734 close-authority shape) → *GateRefusalError (never latches); usage/unknown-flag error mentioning --if-revision (what pre-#4682 bd actually emits) → ErrConditionalWriteUnsupported (LATCHES); isBdAmbiguousWriteError class (bdstore.go:1885) → returned as-is (write MAY have committed); everything else → existing write-error classification (isBdNotFound → ErrNotFound etc.). Build the scriptedRunner test fake (§7.4: calls matched by argv predicate, per-call stdout/exit/err, and an apply func that mutates fake backing state BEFORE returning err — the committed-but-ambiguous cell). TDD: full classifier table as a table-test first. + +- **files:** internal/beads/bdstore_conditional.go, internal/beads/bdstore_conditional_internal_test.go +- **acceptance:** + - Classifier is a pure function — no BdStore receiver state read or written; latching is the CALLER's job keyed on the returned error (keeps probe/latch logic in one place, S2-T5) + - Exit-13 latch decision is body-code-gated, never bare-exit-code-gated (a bare 13 followed by a second write still attempts --if-revision — asserted) + - Exit-9 parse tolerates log-noise-wrapped JSON; misparse degrades to zero-valued PreconditionFailedError, never to a different class + - TestNoBdExecOutsideBeads stays green (all --if-revision argv construction inside internal/beads) +- **tests:** + - bdstore_conditional_internal_test.go: TestClassifyConditionalWriteResult — all seven table rows through the scriptedRunner + - row: exit 9 body {"code":"precondition_failed","expected_revision":4,"current_revision":7} → Expected:4/Current:7 + - row: transport i/o timeout with apply executed → error surfaces as ambiguous class, test asserts NO re-CAS with stale revision happens at this layer +- **depends_on:** S2-T1 + +### [S2-T5] BdStore lazy memoized four-verb capability probe + authoritative runtime latch (one seam) +Extend BdStore (bdstore.go:296 struct) with condWriteMu sync.Mutex, condWriteProbed/condWriteCapable/condWriteLatched bools, and `conditionalWritesCapable() (bool, error)` per DESIGN §8.3, mirroring bdReadyProjectionEnabled (bdstore_ready_projection.go:69-88): lazy (fires on FIRST conditional write, never at construction — no subprocess tax on gc hook), memoized under the mutex, probing `bd --help` for --if-revision across ALL FOUR verbs (update, close, assign, delete) through the EXISTING s.runner seam — there is deliberately NO WithBDCapabilityProbe option (§7.4: one fake runner controls probe and per-call behavior so probe/runtime contradiction is unconstructable). Latch wiring: when any conditional write classifies as ErrConditionalWriteUnsupported (S2-T4 rows), set condWriteLatched=true; the latch is authoritative over the probe in both skew directions (PATH drift, in-place downgrade). Nothing persisted — restart re-probes (no-status-files). Leave a marked seam for the post-tag graduation switch to ProbeBDVersion + deps.CompareVersions vs a future bdConditionalWritesMinVersion deps.env anchor (the bdReadyProjectionMinVersion shape) — comment only, no anchor added in S2. + +- **files:** internal/beads/bdstore.go, internal/beads/bdstore_conditional.go, internal/beads/bdstore_conditional_internal_test.go +- **acceptance:** + - Constructing a BdStore issues zero runner calls (laziness asserted on the scripted fake) + - First conditional write triggers exactly four --help probes; second write issues none (memoized) + - Any single verb missing --if-revision → probe verdict incapable (mid-merge dev bd row) + - After a latch trips, subsequent conditional writes short-circuit to ErrConditionalWriteUnsupported without spawning bd; probe verdict and latch are separately inspectable (doctor renders them separately in S3) + - No test asserts on any on-disk probe artifact (none exists) +- **tests:** + - bdstore_conditional_internal_test.go: TestConditionalWritesProbeLazyMemoizedFourVerb + - TestConditionalWriteLatchAuthoritativeOverProbe (capable probe then runtime unsupported → latched; assert next call skips bd) + - TestBareExit13DoesNotLatch (GateRefusalError → next write still attempts CAS) +- **depends_on:** S2-T4 + +### [S2-T6] BdStore conditional verbs + dedicated CAS retry policy (never the blind transient loop) +Implement UpdateIssueIfMatch/CloseIssueIfMatch/DeleteIssueIfMatch on BdStore in bdstore_conditional.go: check conditionalWritesCapable() → ErrConditionalWriteUnsupported when false; build argv with --if-revision N --json; run through a NEW `runConditionalWrite` wrapper that never routes through runBDTransientWrite/isBdTransientWriteError (bdstore.go:1784/1873) — replaying a stale --if-revision N after a connection error is wrong (first attempt may have committed and bumped) and blind exit-9 retry converts a signal into a spin. Dedicated policy per DESIGN §8.2: connection/serialization-class errors → RE-READ the bead's revision before any re-attempt (bounded attempts, jittered backoff — reuse the bdTransientWriteAttempts/25ms shape); exit 9 → surface *PreconditionFailedError to the caller IMMEDIATELY (caller re-reads and re-decides — that is the point of CAS); ambiguous class (isBdAmbiguousWriteError) → surface as-is, consumers apply the self-win contract (S3); nothing ever downgrades to an unconditional write. Doltlite --dolt-auto-commit prefixing (bdTransientWriteArgs, bdstore.go:1836) must still apply. Add `var _ ConditionalWriter = (*BdStore)(nil)`. + +- **files:** internal/beads/bdstore_conditional.go, internal/beads/bdstore_conditional_internal_test.go +- **acceptance:** + - grep-level + test assertion: no conditional-write path calls runBDTransientWrite/runBDTransientWriteOutput + - Connection-error re-attempt re-reads revision first (scripted fake proves the re-read argv precedes the second --if-revision argv, and the second attempt carries the FRESH revision) + - Exit 9 returns immediately — exactly one bd invocation for that attempt (no internal retry) + - Capability-latched store returns typed unsupported with zero bd spawns + - Argv includes --json on every conditional write so the exit-9 body is machine-parseable +- **tests:** + - bdstore_conditional_internal_test.go: TestConditionalVerbsArgvConstruction (all three verbs, exact argv) + - TestCASRetryRereadsRevisionAfterConnectionError + - TestExit9SurfacesImmediatelyNoBlindRetry + - TestConditionalWriteNeverFallsBackUnconditional (scripted unsupported → assert NO argv without --if-revision is ever issued) +- **depends_on:** S2-T4, S2-T5 + +### [S2-T7] BdStore CompareAndSetMetadataKey: bounded emulation loop + typed exhaustion; bd-sql conditional-UPDATE spike decided +Implement the value-CAS emulation per DESIGN §8.4 pseudocode: loop { Get(id) → if b.Metadata[key] != expected (""≡absent) return (false, nil) genuine loss; runConditionalWrite(update --set-metadata key=next --if-revision b.Revision --json); nil→(true,nil); PreconditionFailedError→retry up to casEmulationMaxAttempts=4 with casEmulationBaseBackoff=25ms doubled+jittered; anything else→(false, err) as-is }. Exhaustion returns *CASRetriesExhaustedError — NOT PreconditionFailedError, NOT (false,nil): the value never mismatched, the store couldn't get a clean shot under cross-key revision churn (metadata-hot control beads); consumers treat it as transient and re-enter level-triggered. SPIKE (decided in this task, before S3): single conditional SQL UPDATE with a JSON-path value predicate via the ReleaseIfCurrent bd-sql template (bdstore.go:1097, incl. releaseIfCurrentViaEmbeddedDoltSQL fallback :1118) to eliminate cross-key interference. Disqualifier the spike must clear: the raw SQL must also atomically bump the revision column itself or it breaks the revision contract for every other conditional writer — if bd's schema/embedded fallback can't guarantee that, the emulation loop SHIPS and the SQL path is dropped, not half-adopted. Record the verdict in engdocs/plans/feature-flags/ as a dated note. + +- **files:** internal/beads/bdstore_conditional.go, internal/beads/bdstore_conditional_internal_test.go, engdocs/plans/feature-flags/ (spike note) +- **acceptance:** + - Loop bounded at 4 attempts; sleep is jittered doubling backoff; never spins unbounded (scripted perpetual-churn row proves exhaustion surfaces) + - Exhaustion error carries ID/Key/Attempts/LastRevision; errors.As distinguishes it from PreconditionFailedError + - Genuine value mismatch short-circuits to (false, nil) WITHOUT issuing a write + - Unsupported/gate-refusal/ambiguous errors pass through unmodified (no retry, no downgrade) + - Spike verdict written down with the revision-bump disqualifier explicitly evaluated; emulation remains the shipping path unless the SQL path clears it +- **tests:** + - bdstore_conditional_internal_test.go: TestCASEmulationWinsFirstShot / TestCASEmulationValueLossIsFalseNil / TestCASEmulationCrossKeyChurnExhaustsTyped (scripted unrelated-key revision bumps between Get and write) + - TestCASEmulationEmptyExpectedClaimsAbsentOnly + - conformance harness row over BdStore+scripted fake where scriptable (unit); authoritative row is S2-T12 integration +- **depends_on:** S2-T6 + +### [S2-T8] CachingStore: forward to backing + EVICT-never-patch (livelock regression test is a MERGE GATE) +Implement ConditionalWriter on CachingStore in caching_store_writes.go following the ReleaseIfCurrent forwarding template at :138 (type-assert c.backing; not implementing → ErrConditionalWriteUnsupported). Cache maintenance deliberately DIVERGES from the existing refreshBeadAfterWrite optimistic-patch fallback (the else-branch in ReleaseIfCurrent that locally patches the clone) — a CAS port of that fallback is poison: a locally-patched entry cannot synthesize the new revision, so every consumer exit-9 recovery re-reads the STALE revision through the cache and re-fails, a livelock indistinguishable from contention. Rule per DESIGN §8.5: CAS success + successful refresh → refresh entry (normal); CAS success + FAILED refresh → delete(c.beads, id) + deps/dirty/deletedSeq bookkeeping, forcing next Get to backing; EVERY PreconditionFailedError from backing → evict too (cached revision proven stale by construction). notifyChange semantics follow the existing write paths. Add `var _ ConditionalWriter = (*CachingStore)(nil)`. + +- **files:** internal/beads/caching_store_writes.go, internal/beads/caching_store_handles.go (if optional-interface plumbing needed), internal/beads/caching_store_test.go +- **acceptance:** + - MERGE GATE: MemStore-backed CachingStore livelock regression green — CAS succeeds, post-write refresh Get scripted to fail once → entry EVICTED (next Get hits backing, sees fresh revision); then a PreconditionFailed → entry evicted; an exit-9 retry loop through the cache CONVERGES instead of re-failing forever + - No code path locally patches a cached bead after any conditional write (review + test assert cache miss after failed refresh) + - Backing store without ConditionalWriter → typed unsupported, cache untouched + - RunConditionalWriterConformance green over CachingStore-over-MemStore + - Existing caching_store write/reconcile suites stay green +- **tests:** + - caching_store_test.go (or new caching_store_conditional_test.go): TestCachingStoreCASEvictsOnFailedRefresh, TestCachingStoreCASEvictsOnPreconditionFailed, TestCachingStoreCASRetryLoopConverges (the livelock regression) + - conformance harness row: CachingStore over MemStore +- **depends_on:** S2-T3 + +### [S2-T9] sqlite ConditionalWriter: new-file-only sqlite_store_conditional.go + revision column migration (deploy-lineage staged) +REALITY CONSTRAINT: origin/main has NO SQLiteStore (DESIGN §10, verified — internal/beads/ has no sqlite files; the store lives on deploy/sqlite-b36-probe-attribution at internal/beads/sqlite_store.go). Author this as a NEW-FILE-ONLY commit (per AGENTS.md upstream-alignment rules) so the identical commit applies to the deploy lineage today and to main when the store is promoted: (a) internal/beads/sqlite_store_conditional.go with CompareAndSetMetadataKey as the single conditional UPDATE per DESIGN §10.2 — guard in the WHERE clause of the committing statement (COALESCE folds no-row to "" so expected=="" claims-if-unset), bead_json AND the metadata index row move in ONE transaction, retryOnBusy wraps the closure (SQLITE_BUSY_SNAPSHOT re-runs with fresh read), loss returns (false,nil), ErrNotFound propagates; revision = revision + 1 in the same UPDATE; (b) the revision-keyed trio (Update/Close/DeleteIssueIfMatch) as WHERE id=? AND revision=?, RowsAffected==0 → in-tx re-read → PreconditionFailedError{Expected, Current} or ErrNotFound; (c) idempotent migration in applySchema: pragma table_info(beads) check + ALTER TABLE beads ADD COLUMN revision INTEGER NOT NULL DEFAULT 0 (schema-only, WAL-safe), upsertBeadTx bumps via the ON CONFLICT arm; (d) unit-CI conformance row (t.TempDir(), pure-Go modernc.org/sqlite driver — no build-tag excuse). The deployed-shape integration test (test/integration/graph_store_sqlite_cas_test.go, 5 legs incl. cross-process) and wrapper-transparency forwarding (lazyGraphStore, beadPolicyStore) are S3's blocking gate — OUT of this stage; document the boundary in the PR. + +- **files:** internal/beads/sqlite_store_conditional.go (new-file-only), internal/beads/sqlite_store_conditional_test.go, deploy-lineage: internal/beads/sqlite_store.go applySchema/upsertBeadTx (minimal patch, isolated commit) +- **acceptance:** + - Commit applies clean (git cherry-pick, no conflicts) onto deploy/sqlite-b36-probe-attribution and compiles+tests green there + - CAS verdict evaluated by SQLite at write time (WHERE clause), never by Go against a possibly-stale snapshot; bead_json/metadata-index lockstep asserted by a re-read agreement test (Get and ListByMetadata return the same value after CAS) + - Migration idempotent: open a pre-revision fixture DB twice; existing rows start at revision 0; CAS works against pre-existing rows + - Mixed-binary ABA note carried in the file doc comment: the revision trio is trustworthy only once every gc binary on the host bumps revision; CompareAndSetMetadataKey is value-CAS and immune — which is why C4/C6 consume only it + - RunConditionalWriterConformance green over SQLiteStore in unit CI on the lineage where it compiles +- **tests:** + - sqlite_store_conditional_test.go: conformance harness row + TestSQLiteCASEpochFenceExclusion (8 goroutines CAS "3"→"4", exactly one winner, siblings byte-identical, revision advanced exactly once) + - TestSQLiteRevisionMigrationIdempotent (pre-revision fixture, double open) + - TestSQLiteCASIndexAndBeadJSONAgree +- **depends_on:** S2-T1, S2-T2 + +### [S2-T10] Factory mode-stamp + ResolveConditionalWriter(store) thin adapter over the GENERAL rollout resolver; NativeDoltStore honesty +Requires Stage 1 (internal/rollout Flags/Mode/Capability resolver, config field, both composition roots). (a) StoreOpenOptions in internal/beads/factory.go gains the resolved rollout.Mode; OpenStoreAtForCity (factory.go:77) stamps it onto EVERY store it opens (bd fallback, native, wrappers) — the mode's ONE home; import direction beads→rollout is legal (rollout has zero beads imports, enforced by S1's boundary test). (b) `ResolveConditionalWriter(store Store) (ConditionalWriter, Diag, error)` in internal/beads — the CAS-owned THIN adapter, NO mode parameter (reads the stamped mode): it supplies the beads capability predicate (type-assert ConditionalWriter on the RESOLVED store — the class_store.go:4-21 lesson: optional interfaces are not promoted through wrappers, assert on the unwrapped .Store — plus the per-store probe/latch state) into rollout's GENERAL resolve(enable, capability) → four-cell product: Off→(nil, legacy, nil); Auto∧capable→writer; Auto∧incapable→(nil, loud-degrade Diag, nil); Require∧incapable→(nil, Diag, typed fail-closed error). (c) beadstest gains WithStampedMode(t, mode) calling the factory's internal stamping path so the store-says-Require/seam-says-Off contradiction is inexpressible in tests (§7.3). (d) NativeDoltStore: the pinned beads library (go.mod, pre-#4682) has no ConditionalWriter surface — so NativeDoltStore does NOT implement the interface in S2; pin that honestly with a test asserting ResolveConditionalWriter over a NativeDoltStore reports incapable with reason "beads library predates ConditionalWriter", and file the S4 delegation follow-up bead referenced in a code comment. + +- **files:** internal/beads/factory.go, internal/beads/conditional_resolve.go (new), internal/beads/conditional_resolve_test.go, internal/beads/beadstest/ (WithStampedMode helper) +- **acceptance:** + - Exactly one home for the mode: no ResolveConditionalWriter caller can pass a mode; grep proves no second stamping path + - Four-cell matrix table-tested at the seam over MemStore with DisableConditionalWrites toggles (auto-degrade cell returns err==nil + Diag; require cell returns typed error; off cell never touches capability) + - internal/rollout still has ZERO beads imports after this task (S1 import-boundary test green) — the general resolver gained nothing beads-shaped + - NativeDoltStore incapable verdict pinned by test with typed reason; follow-up bead ID in the comment + - beadstest.WithStampedMode is the only test path to a stamped mode +- **tests:** + - internal/beads/conditional_resolve_test.go: TestResolveConditionalWriterFourCellMatrix + - TestResolveConditionalWriterAssertsOnResolvedStore (typed class wrapper around MemStore → assert unwrapped .Store is what's probed) + - TestNativeDoltStoreIncapablePreLibraryBump + - internal/rollout boundary test re-run (no new imports) +- **depends_on:** S2-T1, S2-T5 + +### [S2-T11] Typed beads.conditional_writes.degraded event: registered payload + once-per-store latched emission +Per DESIGN §12.2 and the stage-2 line: add the event constant to events.KnownEventTypes (internal/events/events.go:212) and a typed payload struct {Store, Mode, Reason, BDVersion string} registered via events.RegisterPayload in internal/events/payloads.go (the BeadClaimRejected pattern at :83) — TestEveryKnownEventTypeHasRegisteredPayload enforces. Emission: the ResolveConditionalWriter seam (S2-T10) fires it exactly once per store instance when the Auto∧incapable cell is taken (latched alongside the capability latch); Require∧incapable does NOT emit degraded (it refuses — that is doctor-ERROR territory in S3). Emission is a Layer-boundary concern: the seam returns the Diag and the composition layer (which already holds the event bus) publishes — keep internal/beads free of event-bus imports if that is the current layering (verify: caching_store_events.go precedent for how beads-layer changes surface events; follow whichever side of the boundary that file establishes). PRE-COMMIT GOTCHA: the hook skips package guards — run go test ./internal/events/ (and ./internal/beadmeta/ if keys are touched) manually. + +- **files:** internal/events/events.go, internal/events/payloads.go, internal/beads/conditional_resolve.go +- **acceptance:** + - TestEveryKnownEventTypeHasRegisteredPayload green with the new constant + - Payload is a typed struct — no map[string]any/json.RawMessage on the wire type + - Exactly-once-per-store-instance emission asserted (two degraded writes on one store → one event; new store instance → may emit again) + - Auto∧capable and Off emit nothing; Require∧incapable emits nothing on this event (typed refusal instead) + - Event fires with mode+origin context per the diagnostics discipline (first-line mode+origin) +- **tests:** + - internal/events: registration test (existing harness picks it up) + - internal/beads/conditional_resolve_test.go: TestDegradedEventLatchedOncePerStore (fake bus / recorded sink) +- **depends_on:** S2-T10 + +### [S2-T12] BdStore integration conformance row (//go:build integration) + stage exit sweep +(a) Integration row: internal/beads (or test/) file under //go:build integration running RunConditionalWriterConformance against BdStore with a REAL bd — the authority row that makes the in-process rows evidence rather than self-consistent fiction; slot into the Beads↔GasCity contract-test system (PR #3714 scaffolding, internal/beads/contract/). UNTAGGED-#4682 REALITY: the bundled bd (deps.env BD_VERSION, v1.1.0) predates --if-revision, so the row must (i) build bd from beads main (/data/projects/beads) via an env-pinned BD binary path in the harness, and (ii) when the binary lacks capability, assert the DEGRADE contract instead (probe reports incapable; typed unsupported; NO unconditional fallback argv) rather than skipping silently — a ledgered skip for the CAS-semantics legs until a capable bd is pinned. (b) Exit sweep for the stage: make test green; go vet ./... clean; make test-fast-parallel per TESTING.md (NOT monolithic go test ./... — and never bare go test ./cmd/gc/, it times out at 600s); no new GC_* env reads (S1 frozen baseline test green); TestNoBdExecOutsideBeads green; boundary_test.go green; grep-audit: zero call sites convert ErrConditionalWriteUnsupported into a write path; conformance suite green over Mem/File/Caching(/sqlite where compiled); the S2-T8 livelock merge gate green. No consumers exist yet — assert zero production references to ConditionalWriter outside internal/beads (the stage ships machinery, not behavior). + +- **files:** internal/beads/bdstore_conditional_integration_test.go, internal/beads/contract/ (harness slot-in), internal/beads/beadstest/conformance_skips.go (ledger entries if needed) +- **acceptance:** + - Integration row runs against a #4682-capable bd when GC_TEST_BD (or the contract-system equivalent) points at a beads-main build; degrade-contract assertions run against the bundled pre-#4682 bd either way + - Any skipped conformance leg goes through the conformance_skips.go ledger with a tracking bead + expiry — no bare skips + - Full stage gate list green and pasted into the PR description (make test, vet, fast-parallel shards, freeze tests, boundary tests) + - Zero production consumers of ConditionalWriter outside internal/beads (grep-asserted in a boundary test or PR checklist) + - git push succeeds per Session Completion protocol +- **tests:** + - internal/beads/bdstore_conditional_integration_test.go (//go:build integration): conformance harness vs real bd + degrade-contract legs + - re-run of every suite listed in the sweep +- **depends_on:** S2-T6, S2-T7 + +**Exit criteria:** RunConditionalWriterConformance green in unit CI over MemStore, FileStore, CachingStore-over-MemStore (and SQLiteStore on the lineage where it compiles); BdStore integration row wired under //go:build integration with the untagged-bd degrade contract asserted · Classifier fake-runner table fully covered: exit-9-with-body, exit-9-noise-wrapped, exit-13-with-unsupported-code (latches), bare exit-13 (does NOT latch), unknown-flag-mentioning---if-revision (latches), ambiguous-committed (self-win material surfaced as-is), cross-key-churn exhaustion (typed, bounded) · MERGE GATE met: CachingStore livelock regression green (evict on CAS-success-with-failed-refresh AND on every PreconditionFailed; retry loop converges) · No code path converts ErrConditionalWriteUnsupported into an unconditional write — asserted by test (scripted-runner argv assertions) and grep audit; conditional writes provably never route through runBDTransientWrite · Mode has exactly one home (factory stamp); ResolveConditionalWriter takes no mode parameter; four-cell matrix table-tested; internal/rollout retains zero beads imports (S1 boundary test still green) · beads.conditional_writes.degraded registered (TestEveryKnownEventTypeHasRegisteredPayload green) and latched once per store · Stage is consumer-free: zero production call sites of ConditionalWriter outside internal/beads; off-mode behavior byte-identical trivially; all quality gates (make test, go vet, sharded suites, freeze/baseline tests) green and the branch pushed + +_General-Auto: This stage keeps Auto/capability-resolution general by construction: the enable∧capable product is computed by internal/rollout's generic resolver over a rollout.Capability predicate, and everything beads-shaped in S2 lives on the consumer side of that line. ResolveConditionalWriter (S2-T10) is explicitly CAS's thin consumer-owned adapter — it SUPPLIES the beads predicate (resolved-store interface assert + BdStore probe/latch) into the general resolver and adds nothing to rollout itself; the S1 import-boundary test (internal/rollout has zero beads imports) is re-asserted as an acceptance criterion of the one task that touches the seam. The capability machinery built here (per-store probe verdict + authoritative runtime latch + typed unsupported error) is a pattern any future non-beads flag can mirror with its own predicate, while flags with no runtime capability question (daemon.formula_v2) keep supplying no predicate and using Off/Require. The typed-error taxonomy, the conformance-suite discipline (contract-as-doc-comment executed against every implementation), and the evict-never-patch cache rule are likewise beads-local implementation details behind the Store abstraction — nothing in this stage adds a beads assumption, a store type, or a CAS concept to the general subsystem, so a second Auto consumer (e.g. a future runtime-provider capability gate) needs only its own predicate and Spec, not rollout changes._ + +## S3-consumers — Convert the two known lost-update control-plane writers to guarded CAS behind the stage-1 gate: C4 (molecule.Attach epoch fence, CAS-last, losers feed the existing partial-attach recovery) and C6 (exclusive drain reservation, three-outcome self-win contract), with the sqlite ConditionalWriter + integration test against the REAL deployed store shape (deploy/sqlite-b36-probe-attribution, the lineage whose .gc/beads.sqlite actually holds gc.control_epoch and gc.exclusive_drain_reservation) as a BLOCKING merge-gate deliverable. After this stage the flag is real: off is byte-identical to today, auto gives CAS on capable stores with a latched typed degrade event elsewhere, require fails closed with doctor ERROR. + +### [S3.0] Stage-entry preflight: pin S2 exit artifacts and the value-CAS spike verdict +Before any consumer code: (a) verify the S2 surface this stage consumes exists and is frozen — beads.ConditionalWriter (CompareAndSetMetadataKey + the *IfMatch trio), typed errors (PreconditionFailedError{Expected,Current}, ErrConditionalWriteUnsupported, the bounded-emulation exhaustion error distinct from PreconditionFailed), ResolveConditionalWriter(store) returning exactly one of {writer | nil+latched-diagnostic | typed refusal}, the dedicated CAS retry policy that NEVER routes through isBdTransientWriteError (internal/beads/bdstore.go:1790-1830 contains the ambiguous class), and the Mem/File/Caching conformance suite incl. the CachingStore evict-never-patch livelock regression (an S2 merge gate). (b) Record the DESIGN §8.4 stage-2 spike verdict (bd-sql conditional-UPDATE value-CAS via the ReleaseIfCurrent template at bdstore.go:1097 vs the bounded revision-emulation loop) in engdocs/plans/feature-flags/ as a dated decision note — DESIGN.md line 1350 requires it 'decided before C4/C6 land' because it changes whether C6's spurious-conflict branch (BdStore cross-key revision interference) is reachable on bd-backed member stores. (c) Confirm the beads.conditional_writes.degraded event type + ConditionalWritesDegradedPayload were REGISTERED in S2 (internal/events, TestEveryKnownEventTypeHasRegisteredPayload green) so S3 only wires emission. + +- **files:** /data/projects/gascity/.claude/worktrees/reconciler/engdocs/plans/feature-flags/ (decision note, new), /data/projects/gascity/.claude/worktrees/reconciler/internal/beads/bdstore.go (read-only verification) +- **acceptance:** + - A short dated decision note exists in engdocs/plans/feature-flags/ recording the §8.4 spike outcome (SQL value-CAS adopted, or emulation loop stands) with the revision-bump disqualifier explicitly answered + - grep confirms no CAS call path can reach runBDTransientWriteOutputWhen/isBdTransientWriteError; a compile-visible seam (separate method/policy type) separates the two retry disciplines + - The conformance suite is invocable as a store-agnostic function (per-store registration), ready to accept SQLiteStore in S3.3 + - beads.conditional_writes.degraded is in events.KnownEventTypes with a registered payload; emission call count in the tree is zero (S2 registered, S3 emits) +- **tests:** + - Existing S2 suites green as the entry bar: conformance over MemStore/FileStore/CachingStore, CachingStore livelock regression, classifier exit-9/exit-13 tests + - TestEveryKnownEventTypeHasRegisteredPayload (internal/events) green + +### [S3.1] C6: reserveDrainMember → value-CAS with the three-outcome self-win contract; symmetric CAS release +TDD in internal/dispatch/drain.go. Write the failing tests first (see tests), then port reserveDrainMember (drain.go:1223-1246). Shape: resolve cw := beads.ResolveConditionalWriter(memberStore) on the store returned by drainMemberOwningStore(store, member.ID, opts) (drain.go:307) — store routing is UNCHANGED and capability is asserted per member on ITS owning store, so a mixed topology degrades only the members the incapable store owns. Verdicts: cw==nil+diagnostic (off, or auto∧incapable) → take today's read-then-write branch byte-identical; typed refusal (require∧incapable) → propagate as error (drain skips/retries next level-triggered tick, doctor is already ERROR). CAS path: CompareAndSetMetadataKey(member.ID, beadmeta.ExclusiveDrainReservationMetadataKey, "", control.ID); ok → claimed. On ok==false/PreconditionFailed: NEVER a loss verdict — re-read via memberStore.Get and apply the three-outcome table from DESIGN §9.1: owner==control.ID → nil (SELF-WIN: idempotent re-entry AND the committed-but-unacknowledged ambiguous-write case); owner=="" → one bounded re-issue (retryReserveOnce; second spurious failure surfaces as transient, next tick retries); owner==other → drainReservationError (skip member, existing type at drain.go:1207). Transport/exhaustion errors that are not PreconditionFailed surface wrapped with control.ID+member.ID context. ErrNotFound on re-read → nil (existing contract; also covers retention-sweeper deletion between read and CAS). Release rides the same task: releaseDrainReservations (drain.go:1257) becomes CompareAndSetMetadataKey(memberID, key, control.ID, "") on the member's owning store — losing that CAS means a successor drain re-claimed, clearing would be the clobber, so loss is logged at debug and never retried. The legacy branch stays as dead-simple else-code (no refactor) so the off-mode byte-identical assertion is trivially true. + +- **files:** /data/projects/gascity/.claude/worktrees/reconciler/internal/dispatch/drain.go, /data/projects/gascity/.claude/worktrees/reconciler/internal/dispatch/drain_test.go (or new drain_cas_test.go beside it) +- **acceptance:** + - With mode=off (and with ResolveConditionalWriter returning nil), reserveDrainMember and releaseDrainReservations execute the pre-stage byte-identical read-then-write path — asserted by a test that fails if the legacy branch's store-call sequence changes (golden call-trace against a recording fake store) + - The naive-port bug is impossible: a re-entered drain whose control already owns the member returns nil, not skip (the DESIGN §9.1 middle-row collapse is regression-tested) + - No code path converts PreconditionFailed or ErrConditionalWriteUnsupported into an unconditional SetMetadata; grep + a unit test on the require∧incapable cell prove the refusal propagates + - Exit-9 handling always begins with a re-read, never a conclusion (PreconditionFailed is an observation, not a value fact — BdStore revision emulation can conflict on an unrelated key) + - Release loss is terminal-at-debug: no retry loop, no error surfaced to the drain outcome + - All four cells of the mode×capability matrix are tested for C6: off→legacy; auto∧capable→CAS; auto∧incapable→legacy+once-latched diagnostic; require∧incapable→typed refusal +- **tests:** + - TestReserveDrainMemberCAS_Contention (MemStore): two controls race one member; exactly one owns; loser gets drainReservationError + - TestReserveDrainMemberCAS_ReEntry: reserve, re-enter same drain, assert nil not skip; member remains owned + - TestReserveDrainMemberCAS_AmbiguousRetrySelfWins (fake CommandRunner per DESIGN §9.3): runner commits the write then returns i/o timeout; retry re-reads, finds own control.ID, returns nil — member stays reserved, no skip + - TestReserveDrainMemberCAS_SpuriousConflict: inject PreconditionFailed with the key still empty; assert exactly one bounded re-issue then success + - TestReleaseDrainReservationsCAS_LossIsNoop: successor drain re-claimed the member; release CAS loses; assert owner unchanged and no error + - TestReserveDrainMember_FourCellMatrix + TestReserveDrainMember_OffByteIdentical (recording store, golden op sequence) + - TestReserveDrainMember_MixedOwningStores: graph store capable, one rig member store incapable under auto — only that member degrades, others CAS +- **depends_on:** S3.0 + +### [S3.2] C4: Attach epoch fence goes CAS-last; loser neutralizes via existing partial-attach recovery; syncControlEpochToAttempt collapses onto the same helper +TDD in internal/molecule/molecule.go + internal/dispatch/control.go. Port per DESIGN §9.2, CAS-LAST pinned (CAS-first rejected: a crash after CAS before Instantiate burns the epoch with no idempotency record and permanently skews syncControlEpochToAttempt's attempt-numbering). Steps: (1) keep the early cheap epoch check (molecule.go:~258-267) exactly as-is — fast-fail, byte-identical when ExpectedEpoch==0; (2) keep findExistingAttach BEFORE the fence (molecule.go:~250) and promote that ordering to a documented contract on AttachOptions.ExpectedEpoch — it is load-bearing for the ambiguity contract (the epoch value expected+1 is non-writer-identifying, so a false loss on an ambiguous transport error is tolerated ONLY because idempotency runs first); write the conditional warning comment at BOTH seam sites (AttachOptions and the CAS call) saying reordering voids the tolerance; (3) replace the trailing store.SetMetadata epoch increment (molecule.go:~308-311) with cw.CompareAndSetMetadataKey(attachBeadID, beadmeta.ControlEpochMetadataKey, itoa(opts.ExpectedEpoch), itoa(opts.ExpectedEpoch+1)) where cw resolves from the store Attach already holds (mode invisible at the call site); cw==nil → legacy SetMetadata branch byte-identical; (4) LOSER PATH (ok==false after Instantiate+DepAdd side effects exist): Attach itself neutralizes what it just created — (a) markFailed walk (molecule.go:1294) stamps molecule_failed=true on all created beads, making the orphan root discoverable by failedAttemptAttachRootID's query (control.go:560) and skippable by findExistingAttach's molecule_failed guard (molecule.go:343); (b) store.DepRemove(attachBeadID, result.RootID) detaches the blocking edge so the attach bead cannot wedge on an orphan root; (c) return ErrEpochConflict so the dispatch layer's existing wrap (control.go:519-525) classifies it as partialAttemptAttachError → markControllerSpawnError (control.go:316-320) treats it hard-for-this-attempt, not transient-retry; the next level-triggered pass converges on the WINNER via findExistingAttach — zero new recovery machinery; (5) syncControlEpochToAttempt (control.go:304) → CompareAndSetMetadataKey(control.ID, key, itoa(current), itoa(attemptNum)); its exit-9 is benign by construction (another processor advanced first): re-read; current>=attemptNum → nil; else re-issue once. + +- **files:** /data/projects/gascity/.claude/worktrees/reconciler/internal/molecule/molecule.go, /data/projects/gascity/.claude/worktrees/reconciler/internal/molecule/molecule_test.go, /data/projects/gascity/.claude/worktrees/reconciler/internal/dispatch/control.go, /data/projects/gascity/.claude/worktrees/reconciler/internal/dispatch/control_test.go +- **acceptance:** + - ExpectedEpoch==0 and mode=off paths are byte-identical to today (golden call-trace test on a recording store) + - CAS-last ordering and the findExistingAttach-before-fence contract are enforced by comments at both seam sites AND by a test that exercises the crash-retry: fail after CAS-loss cleanup, re-enter, converge on the winner + - The loser leaves the ledger convergent: its root carries molecule_failed=true, the attach bead has NO inbound blocking edge from the loser root, and the winner's sub-DAG is the one findExistingAttach returns + - The loser's error classifies as hard-for-this-attempt (partialAttemptAttachError path), never transient-retry — asserted against markControllerSpawnError's metadata output (ControllerErrorClassMetadataKey=hard) + - syncControlEpochToAttempt's exit-9 contract holds: re-read, current>=attemptNum → nil, else exactly one re-issue + - Four-cell mode×capability matrix tested for C4; require∧incapable refuses the epoch advance with the typed error and no side-effect writes beyond the already-specified loser cleanup +- **tests:** + - TestAttachEpochFence_ConcurrentAttach (integration-style, MemStore, stage-3 merge gate): two concurrent Attach calls sharing an idempotency key and ExpectedEpoch; exactly one sub-DAG survives live; loser root molecule_failed=true with no inbound blocking edge from the attach bead; a third re-entrant call returns the winner via findExistingAttach + - TestAttachEpochFence_LoserCleanupOrder: CAS loses; assert markFailed ran over exactly the just-created IDs, DepRemove(attachBeadID, loserRoot) ran, ErrEpochConflict returned + - TestAttachEpochFence_AmbiguousError (fake CommandRunner): ambiguous transport error injected AFTER the committed epoch CAS; retry converges via findExistingAttach idempotency with exactly one live sub-DAG (the tolerated false-loss leg of DESIGN §9.3) + - TestSyncControlEpochToAttempt_CASBenignLoss: competitor advances epoch between read and CAS; assert nil when current>=attemptNum and single re-issue otherwise + - TestAttach_OffByteIdentical + TestAttach_FourCellMatrix + - TestDispatch_PartialAttachClassification: end-to-end through control.go — epoch-loser error surfaces as partialAttemptAttachError and markControllerSpawnError stamps FailureClassHard +- **depends_on:** S3.0 + +### [S3.3] sqlite ConditionalWriter (BLOCKING core): sqlite_store_conditional.go on the deploy lineage, new-file-only +TDD on deploy/sqlite-b36-probe-attribution, where SQLiteStore exists (origin/main has none — resolveClassStore at cmd/gc/class_store.go:231 is an identity seam on main; the C4/C6 code lands on main but the fence EXECUTES on the deploy lineage). New file internal/beads/sqlite_store_conditional.go per DESIGN §10.2, new-file-only so the identical commit cherry-picks to main when SQLiteStore is promoted. Contents: (1) var _ ConditionalWriter = (*SQLiteStore)(nil); (2) CompareAndSetMetadataKey as the single conditional UPDATE — deferred BeginTx on the MaxOpenConns=1 write conn inside retryOnBusy; getTx read; mutate bead_json in Go; the guard IS the WHERE clause of the committing UPDATE (COALESCE((SELECT meta_value FROM metadata WHERE bead_id=? AND meta_key=?), '') = ?) so the verdict is evaluated by SQLite at write time, never by Go against a stale snapshot; COALESCE folds no-row to "" so expected=="" means claim-if-unset (the exact drain shape); RowsAffected==0 → deferred rollback, (false, nil); guard passed → upsert the metadata index row in the SAME tx (both representations move together or the store is corrupt: bead_json is canonical for Get, metadata table feeds ListByMetadata/idx_metadata_key_value); revision = revision + 1 in the UPDATE; (3) revision column migration in applySchema: pragma table_info(beads) check + ALTER TABLE beads ADD COLUMN revision INTEGER NOT NULL DEFAULT 0 — ADD COLUMN is schema-only, safe against a WAL file other processes hold open; idempotent (open twice); (4) upsertBeadTx bumps revision on the ON CONFLICT arm, insert arm starts at 1; bead_json never carries revision (column authoritative); (5) the revision-keyed trio UpdateIssueIfMatch/CloseIssueIfMatch/DeleteIssueIfMatch: WHERE id=? AND revision=?; RowsAffected==0 → in-tx re-read → PreconditionFailedError{Expected, Current} or ErrNotFound (capability is all-or-nothing interface satisfaction, so the full interface ships even though C4/C6 consume ONLY value-CAS — the mixed-binary ABA rule: an old gc binary's upsertBeadTx never bumps revision, so revision-CAS on this file is trustworthy only once every binary on the host bumps; value-CAS compares the value itself and is immune); (6) register SQLiteStore in the store-agnostic conformance suite in UNIT CI (t.TempDir(), pure-Go modernc.org/sqlite driver, CGO_ENABLED=0 — no build-tag excuse). + +- **files:** deploy/sqlite-b36-probe-attribution: internal/beads/sqlite_store_conditional.go (new), deploy/sqlite-b36-probe-attribution: internal/beads/sqlite_store_conditional_test.go (new), deploy/sqlite-b36-probe-attribution: internal/beads/sqlite_store.go (applySchema + upsertBeadTx minimal diff) +- **acceptance:** + - The commit touches ONLY new files plus the minimal applySchema/upsertBeadTx diffs, and applies clean (git cherry-pick) onto a branch where SQLiteStore is promoted — verified by actually cherry-picking onto a throwaway main-based branch with the sqlite store files copied in + - Loss returns (false, nil); missing bead returns ErrNotFound from the read (never a false win — covers retention-sweeper deletion between read and CAS) + - After a won CAS, Get (bead_json) and ListByMetadata (index) agree on the new value in the same process and from a second connection + - Concurrent sibling-key writes cannot be clobbered: an intervening commit between getTx and UPDATE triggers SQLITE_BUSY_SNAPSHOT → retryOnBusy re-runs against a fresh read (guard is defense-in-depth, not the only line) + - Schema migration is idempotent on a fixture file created with the pre-revision schema verbatim; existing rows read revision=0; no row rewrite occurs + - SQLiteStore passes the FULL ConditionalWriter conformance suite in unit CI on the deploy lineage +- **tests:** + - Conformance suite registration: TestConditionalWriterConformance/sqlite (unit, t.TempDir) + - TestSQLiteCAS_ClaimIfUnset: expected="" claims an absent metadata row; second claim with expected="" loses (false,nil) + - TestSQLiteCAS_SiblingKeysByteIdentical: CAS on gc.control_epoch leaves all sibling metadata keys and unrelated bead_json fields byte-identical; revision advanced exactly once + - TestSQLiteCAS_BothRepresentations: after win, direct SQL against metadata table equals Get().Metadata[key] + - TestSQLiteSchema_RevisionMigrationIdempotent: open a pre-revision fixture twice; assert single column add, rows at 0, CAS works against pre-existing rows + - TestSQLiteIfMatchTrio_PreconditionFailed: stale revision → PreconditionFailedError carrying Expected and Current (the forensics contract of DESIGN §12.3) +- **depends_on:** S3.0 + +### [S3.4] Wrapper transparency: ConditionalWriter must survive every wrapper on the resolved store path +The controller never holds a bare *SQLiteStore; optional interfaces are NOT promoted through hand-rolled delegation (the class_store.go lesson — beadPolicyStore already dropped ListGraphOnlyHandle once, and main already carries the exact precedent pattern: var _ beads.ConditionalAssignmentReleaser = (*beadPolicyStore)(nil) at cmd/gc/bead_policy_store.go:38). Per DESIGN §10.4: (a) deploy lineage — noCloseGraphStore (cmd/gc/api_state.go:320, embeds the concrete pointer, promoted automatically; add the var _ assert anyway) and lazyGraphStore (hand-rolled per-method delegation; MUST add explicit forwarding for CompareAndSetMetadataKey and the trio). Forwarding rule for lazyGraphStore while unhealed: return the OPEN ERROR, never ErrConditionalWriteUnsupported — a transient open failure must fail loud, not latch the store incapable and silently degrade auto to legacy. (b) main — beadPolicyStore/beadPolicyGraphStore (cmd/gc/bead_policy_store.go): explicit forwarding with capability pass-through (if the wrapped store lacks the interface, the wrapper must not claim it — conditional forwarding via type assert, mirroring the ConditionalAssignmentReleaser handling). CachingStore forward+evict landed in S2; assert only. (c) The wrapper-transparency test resolves the graph store EXACTLY as the controller registers it (deploy: graph_store="sqlite" → graphStoreHandleCache shared handle → noClose → lazy → policy wrap; main: identity resolveClassStore → policy wrap over the work store) and asserts the RESOLVED value satisfies ConditionalWriter iff the innermost store does. + +- **files:** deploy/sqlite-b36-probe-attribution: cmd/gc/api_state.go (lazyGraphStore/noCloseGraphStore forwarding), /data/projects/gascity/.claude/worktrees/reconciler/cmd/gc/bead_policy_store.go, /data/projects/gascity/.claude/worktrees/reconciler/cmd/gc/class_store_test.go +- **acceptance:** + - Resolved-path assert: on the deploy lineage, the store returned by the controller's graph-class registration satisfies beads.ConditionalWriter; on main, capability of the resolved store equals capability of the wrapped store (no wrapper manufactures or destroys capability) + - Unhealed lazyGraphStore returns the open error from CompareAndSetMetadataKey — a test injects an open failure and asserts errors.Is on the open error and NOT ErrConditionalWriteUnsupported + - var _ ConditionalWriter compile asserts exist on every wrapper that forwards unconditionally; conditional wrappers (policy store) have a runtime pass-through test in both directions (capable inner → capable outer; incapable inner → incapable outer) +- **tests:** + - TestGraphStoreResolvedPathConditionalWriter (deploy lineage, unit): temp city, [beads] graph_store="sqlite", resolve through controller registration, assert interface satisfaction on the resolved value + - TestLazyGraphStoreUnhealedReturnsOpenError (deploy lineage) + - TestBeadPolicyStoreConditionalWriterPassthrough (main): both directions + - Extend cmd/gc/class_store_test.go's existing wrapper-unwrap table to include ConditionalWriter +- **depends_on:** S3.3 + +### [S3.5] BLOCKING integration test: graph_store_sqlite_cas_test.go against the deployed store shape +test/integration/graph_store_sqlite_cas_test.go, //go:build integration, staged on the deploy lineage (templates for topology and the second-process harness: test/integration/graph_store_sqlite_convergence_test.go and test/agents/graph-store-sqlite-worker.sh, both verified present on deploy/sqlite-b36-probe-attribution). Five named subtests per DESIGN §10.5: (1) ResolvedPathCapability — temp city with [beads] graph_store="sqlite"; resolve through the controller registration path; assert ConditionalWriter satisfaction on the resolved store; assert an unhealed lazy store returns the open error not ErrConditionalWriteUnsupported. (2) EpochFenceExclusionInProcess — seed a gcg- control bead with gc.control_epoch="3" plus sibling metadata; 8 goroutines CAS "3"→"4"; exactly one true; final value "4"; sibling keys byte-identical; revision advanced exactly once. (3) DrainReservationExclusionCrossProcess — the deployed contention is controller-vs-CLI on one .gc/beads.sqlite: a second OS process (worker-script harness) hammers CompareAndSetMetadataKey(member, gc.exclusive_drain_reservation, "", ) across M members while the test process competes with its own ID; assert exactly one owner per member, losers observed false, no SQLITE_BUSY leaks through retryOnBusy, and index-vs-bead_json agreement on re-read (Get and ListByMetadata return the same owner). (4) DeployedFileMigration — open a checked-in fixture beads.sqlite created with the pre-revision schema verbatim and populated rows carrying gc.control_epoch; assert idempotent migration (open twice), CAS works against pre-existing rows, revisions start at 0. (5) BusySnapshotRetry — pin the WAL write lock past busy_timeout from a helper connection; assert the CAS converges to a correct verdict after retry, never a false win. GATE WORDING (verbatim from the design): the C4/C6 merge checklist names this file green ON THE LINEAGE THE FLEET DEPLOYS FROM — main's identity resolveClassStore means main-only green proves nothing about the deployed fence. + +- **files:** deploy/sqlite-b36-probe-attribution: test/integration/graph_store_sqlite_cas_test.go (new), deploy/sqlite-b36-probe-attribution: test/agents/ (second-process CAS worker script, new, modeled on graph-store-sqlite-worker.sh), deploy/sqlite-b36-probe-attribution: test/integration/testdata/ (pre-revision beads.sqlite fixture generator + fixture) +- **acceptance:** + - All five legs are named subtests, green under `go test -tags integration` on deploy/sqlite-b36-probe-attribution (run via the documented shard targets, not a monolithic sweep) + - The cross-process leg uses a real second OS process (not a goroutine standing in), following the graph-store-sqlite-worker.sh harness pattern + - The pre-revision fixture file is generated by a checked-in helper from the OLD schema DDL (not hand-binary-edited), so the fixture is reproducible and reviewable + - No leg fixes flakiness by extending timeouts (repo rule from the CI flake audit); busy contention is bounded by deterministic lock-hold windows + - The merge checklist for the stage names this file and the lineage explicitly +- **tests:** + - This task IS the test. Plus: the sqlite conformance run stays in unit CI (only the multi-process leg needs the integration tag) +- **depends_on:** S3.3, S3.4 + +### [S3.6] Observability goes live: degraded-event emission, mode+origin log discipline, doctor graph-class row + FAIL-CLOSED exit contract +Per DESIGN §12 (registered in S2, EMITTED in S3): (1) Emission — the first capability veto on a store fires beads.conditional_writes.degraded exactly once per store instance, guarded by the same mutex as the capability latch (log storms structurally impossible, mirroring native_store_unavailable); internal/beads is Layer 0 and must not import the event bus, so the factory-injected nil-safe callback OpenOptions.OnConditionalWritesDegraded carries the payload out; wire it to the bus at both composition roots wherever a bus exists (controller store-open path around cmd/gc/api_state.go:104 controllerStateOpenRigStoreAtForCity / beads.OpenStoreAtForCity, and the API server root); short-lived CLI paths without a bus fall back to the structured log alone. Payload fields per the registered type: store_id, store_kind, mode, origin, reason, bd_version. (2) Log discipline — every refusal and degrade line carries mode + origin in its first line (break-glass env is per-process, so two processes of one city can legitimately resolve opposite modes); store-open veto under auto emits the factory-style BeadsDiagnostic{PreflightGate:"conditional_writes"} plus one conditional_writes_unavailable structured log per store; every PreconditionFailedError log carries Expected/Current so contention vs stale-cache vs cross-key interference are distinguishable from text. (3) Doctor (cmd/gc/cmd_doctor.go family) — the Rollout Flags section from S1 gains per-store verdicts: the graph-class store is ALWAYS rendered as its own row (until the sqlite test soaks, this row is the operator's only honest view of whether the epoch fence is real where it matters); probe and latch are separate columns always; effective status aggregation worst-of fail_closed > degraded > pending-restart > active > off; the mixed-writer warning renders when mode=auto ∧ any store DEGRADED ∧ declared multi-writer topology. Exit contract per §12.1.2: FAIL-CLOSED → ERROR nonzero; DEGRADED → WARNING exit 0. (4) Require preflight — store-open under require∧incapable emits the typed refusal diagnostic at open, not first-write. + +- **files:** /data/projects/gascity/.claude/worktrees/reconciler/internal/beads/ (factory OpenOptions callback wiring; emission latch beside the capability latch), /data/projects/gascity/.claude/worktrees/reconciler/cmd/gc/api_state.go (bus wiring at store-open), /data/projects/gascity/.claude/worktrees/reconciler/internal/api/ (API-server composition root wiring), /data/projects/gascity/.claude/worktrees/reconciler/cmd/gc/cmd_doctor.go (+ new doctor rollout rendering file beside it) +- **acceptance:** + - Exactly one degraded event per store instance regardless of veto count — hammered concurrently in a test; payload passes the typed-events invariant + - A city with no event bus (bare CLI) degrades with a structured log and no panic/nil deref (nil-safe callback) + - Doctor renders the graph-class store row with kind and reason (e.g. store=graph kind=sqlite capable=false reason="SQLiteStore predates ConditionalWriter") on a fixture city — never folded into an aggregate boolean + - TestDoctorRolloutExitContract cells relevant to S3 pass: require∧incapable → ERROR + nonzero exit; auto∧incapable → WARNING + exit 0 + - Mixed-writer warning appears iff (auto ∧ DEGRADED ∧ multi-writer topology) — three-way table test + - No new hand-written JSON on any wire path; if any doctor/API surface changed types, make dashboard-check is green (expected: no wire change in S3 — status wire rides S4) +- **tests:** + - TestConditionalWritesDegradedLatchedOncePerStore (concurrent vetoes, one event) + - TestOpenStoreWiresDegradedCallbackAtBothRoots (controller + API server composition roots — entry-point tests, the wiring is the contract) + - TestDoctorRendersGraphClassStoreRow + TestDoctorMixedWriterWarning + TestDoctorRolloutExitContract (S3 cells) + - TestRefusalLogCarriesModeAndOrigin (log-line golden on first line fields) +- **depends_on:** S3.1, S3.2 + +### [S3.7] Runbook and seam documentation: interim rules, mixed-writer invariant, ABA rule — shipped in the same PR +docs/runbooks/conditional-writes.md (docs/runbooks/ exists; managed-city-endpoints.md is the layout template) carrying the DESIGN §10.6 text verbatim: (1) require is FORBIDDEN while the running gc predates the sqlite ConditionalWriter — every molecule.Attach epoch advance and every exclusive-drain reservation would refuse → controller-wide stall; gc doctor renders this ERROR before you deploy it, believe it; (2) auto is safe but a no-op for graph-class writes until the deliverable lands (DEGRADED, typed event, today's TOCTOU retained); (3) the lift conditions: sqlite ConditionalWriter + deployed-topology integration test merged on the lineage the fleet deploys (deploy/sqlite-b36-probe-attribution today, NOT origin/main); conformance suite green including sqlite; >=1 week soak on maintainer-city at auto with zero degraded events from the graph store and doctor showing graph=capable; (4) the ABA rule: revision-keyed CAS on this file is trustworthy only when every gc binary on the host runs a revision-bumping build — value-CAS is immune and C4/C6 use only value-CAS; (5) the fleet-scoped invariant verbatim: CAS mutual exclusion on a ledger holds only when every writer to that ledger is CAS-active or exactly one writer exists — mixed writers on one .gc/beads.sqlite are the controller PLUS every short-lived gc CLI process on the host; flip modes via city.toml + controller restart only; a per-process GC_BEADS_CONDITIONAL_WRITES splits the writer set on a single file. Also in this task: the ambiguity-contract seam comments at the C4/C6 call sites (written in S3.1/S3.2, reviewed here as a deliverable), and a pointer from engdocs/contributors/reconciler-debugging.md-style docs if drain/epoch incidents reference the new error shapes. + +- **files:** /data/projects/gascity/.claude/worktrees/reconciler/docs/runbooks/conditional-writes.md (new), /data/projects/gascity/.claude/worktrees/reconciler/internal/molecule/molecule.go (seam comments), /data/projects/gascity/.claude/worktrees/reconciler/internal/dispatch/drain.go (seam comments) +- **acceptance:** + - The runbook page exists with the five rule blocks above, verbatim where the design says verbatim + - gc doctor's FAIL-CLOSED message and the runbook name each other (operator can navigate from the ERROR line to the runbook) + - Seam comments exist at BOTH sites (AttachOptions.ExpectedEpoch contract; the CAS call's tolerance conditional) and state that reordering findExistingAttach after the epoch check voids the ambiguity tolerance +- **tests:** + - Docs lint/build if configured; grep-based test asserting the seam comments exist is overkill — review-gated instead (stated honestly) +- **depends_on:** S3.1, S3.2, S3.5 + +### [S3.8] Stage gate: dual-lineage landing choreography, merge checklist, and soak initiation +Stage 3 lands on TWO targets and the gate binds them: (A) origin/main PR — S3.1 (C6), S3.2 (C4), main-side wrapper transparency (S3.4b), observability (S3.6), runbook (S3.7); on main the consumers run against Mem/File/Caching/Bd stores and the identity resolveClassStore. (B) deploy/sqlite-b36-probe-attribution PR — S3.3 (sqlite ConditionalWriter, new-file-only), S3.4a (lazy/noClose forwarding), S3.5 (the blocking integration test), plus a cherry-pick of the main PR (the maintainer fleet runs this lineage — memory: dispatch-hard-fail-fixes — so base gc changes on the sqlite branch). MERGE CHECKLIST (all boxes required before either PR merges; the design promotes these from footnotes to gates): [1] four-cell mode×capability matrix tests green per consumer (C4 and C6 separately); [2] off-mode byte-identical golden assertions green; [3] conformance suite green over Mem/File/Caching/Bd(integration-tagged)/sqlite; [4] test/integration/graph_store_sqlite_cas_test.go green ON THE DEPLOY LINEAGE — main-only green proves nothing; [5] no CAS call routed through isBdTransientWriteError (compile-visible seam + grep); [6] degraded event emission latched, doctor exit contract green; [7] repo quality gates: make test (or test-fast-parallel), go vet ./..., .githooks/pre-commit active, targeted package-guard tests run manually (internal/beadmeta, internal/events — the pre-commit hook skips package guards per project memory), new test packages carry the generated testenv_import_test.go (go run scripts/add-testenv-import.go); [8] runbook shipped. POST-MERGE (stage exit, not merge-blocking): flip maintainer-city to conditional_writes=auto and begin the >=1 week soak; the stage is EXITED when soak telemetry shows zero graph-store degraded events and doctor graph=capable — only then may anyone recommend require anywhere, per the stage-3 gate line and the runbook lift conditions. + +- **files:** /data/projects/gascity/.claude/worktrees/reconciler/engdocs/plans/feature-flags/ (stage-3 checklist + soak record), both lineages: PR descriptions / merge checklists +- **acceptance:** + - Both PRs reference the same milestone and each other; the deploy-lineage PR's description carries the merge checklist with evidence links (CI runs, test names) + - git cherry-pick of the sqlite commits onto a main-shaped branch is verified clean (the new-file-only discipline held) + - Soak is initiated with a dated bd bead tracking it (owner = the registry Spec's Owner.Bead lineage), and the runbook's lift conditions reference that bead + - No git push of either PR happens without the full checklist green; work is not complete until push succeeds (session-completion rule) +- **tests:** + - The checklist itself — every referenced suite named and green; plus one end-to-end smoke on the deploy lineage: temp city, graph_store=sqlite, conditional_writes=auto, run a drain + an Attach through dispatch, assert CAS was the executed path (event/log evidence), then same city at off asserting legacy path +- **depends_on:** S3.1, S3.2, S3.3, S3.4, S3.5, S3.6, S3.7 + +**Exit criteria:** C6 reserveDrainMember/releaseDrainReservations and C4 molecule.Attach/syncControlEpochToAttempt execute value-CAS (CompareAndSetMetadataKey only — never the revision trio) when the gate resolves active, with the three-outcome self-win contract (C6) and CAS-last + existing-partial-attach-recovery loser wiring (C4) implemented exactly as written contracts, not hand-waved re-reads · off is byte-identical to today on every touched path, proven by golden call-trace assertions that fail on any legacy-branch drift · The four-cell mode×capability matrix is tested per consumer; no code path anywhere converts PreconditionFailed or ErrConditionalWriteUnsupported into an unconditional write; CAS never routes through the isBdTransientWriteError blind retry loop · BLOCKING: internal/beads/sqlite_store_conditional.go (single conditional UPDATE, WHERE-clause guard, both representations in one tx, idempotent revision-column migration) plus test/integration/graph_store_sqlite_cas_test.go with all five legs are green ON deploy/sqlite-b36-probe-attribution — the lineage the fleet deploys from; sqlite additionally passes the conformance suite in unit CI · ConditionalWriter survives the full resolved wrapper path (noCloseGraphStore, lazyGraphStore with open-error-not-unsupported while unhealed, beadPolicyStore/beadPolicyGraphStore), asserted by the wrapper-transparency test through the controller's real registration path · beads.conditional_writes.degraded emits once-latched per store through the nil-safe factory callback wired at both composition roots; gc doctor renders the graph-class store as its own row with probe/latch split, the mixed-writer warning, and the FAIL-CLOSED→nonzero / DEGRADED→zero exit contract · The runbook ships in the same PR with the verbatim interim rules: require forbidden on the deployed topology until the sqlite test soaks; the fleet-scoped mixed-writer invariant; the value-CAS-vs-revision-CAS ABA rule · Repo gates green on both lineages (make test / vet / pre-commit / manual package-guard tests / testenv imports); both PRs pushed; maintainer-city soak at auto initiated and tracked in a bead — require is not recommended anywhere until >=1 week of zero graph-store degraded events + +_General-Auto: Stage 3 adds ZERO lines to internal/rollout and zero beads knowledge to the resolver — that is the proof the S1 abstraction held. Consumers never see the flag: C4 and C6 call beads.ResolveConditionalWriter(store) on whatever store they already hold (mode was factory-stamped at store-open by the general resolver's verdict), so per-store capability heterogeneity is handled where it exists — on the store — not threaded through consumer options. The capability veto is the GENERAL rollout.Capability predicate mechanism doing enable∧capable; beads.ResolveConditionalWriter is merely CAS's thin consumer-owned adapter over it. Everything stage 3 builds that looks flag-adjacent is registry-driven and reusable: the degraded-event latch pattern (once-per-instance, mutex-shared with the capability latch) is the template for any future Auto-mode flag's degrade surface; doctor's per-store verdict rows render from the registry Spec plus a generic per-store capability array, so the next capability-gated flag gets its rows for free; the four-cell matrix test shape (off byte-identical / auto∧capable / auto∧incapable / require∧incapable) is the reusable acceptance template for every future rollout-gate consumer. The import-boundary invariant (internal/rollout imports no beads) remains test-enforced and untouched by this stage._ + +## S4-libbump-wire — Bump the beads library (go.mod github.com/steveyegge/beads) and the bundled bd CLI (deps.env BD_VERSION + install-bd-archive.sh SHA table + workflow pins) to the first release carrying beads#4682, transition CAS capability detection from the interim four-verb help-grep to the version-anchored probe (new deps.env BD_CONDITIONAL_WRITES_MIN_VERSION + internal/beads const under the TestBDVersionPins lockstep, arming the two-stage graduation test), and land the C2 API optimistic-concurrency wire change atomically with the go.mod bump: Bead.Revision on the wire, ETag/If-Match/412/501 handler semantics, the beads_conditional_writes status-wire struct, full OpenAPI regen (genspec + genclient + docs/reference/schema copies), restored dashboard TS generation, and green make spec-ci + make dashboard-check. Ships as two independently mergeable PRs: PR-A (bd pin + capability anchor, no wire change) and PR-B (library bump + wire + C2, atomic because TestOpenAPISpecInSync forces it). + +### [S4-T0] Entry gate: verify a tagged beads release carries #4682 and map the two version knobs +S4 is blocked until gastownhall/beads tags a release containing #4682. Verify against the real artifacts, not the PR description: (1) download the release tarball via the same path .github/scripts/install-bd-archive.sh uses and confirm `bd update --help`, `bd close --help`, `bd assign --help`, `bd delete --help` all advertise --if-revision; (2) confirm exit 9 emits the machine JSON body {code, expected_revision, current_revision} and exit 13 unsupported emits body code conditional-write-unsupported (drive a scratch bd repo); (3) confirm the Go library at that tag exposes Issue.Revision (int64) — this is what internal/beads/native_dolt_store.go's beadslib.Issue conversions will map. Record the mapping between the two knobs (memory: 'bump beads = two knobs'): the bd CLI tag for deps.env BD_VERSION vs the Go module version for go.mod github.com/steveyegge/beads (module path is steveyegge/beads while the repo/pin table says gastownhall/beads — confirm which tag the module proxy serves). If no tagged release exists yet, S4 stalls here; the only permitted early motion is bumping the contract-matrix bleeding-edge cell (deps.env BD_CURRENT_VERSION/BD_CURRENT_REF, built from source) to a #4682-containing commit for early contract coverage, per the coordination protocol in engdocs/design/beads-gascity-contract-test-system.md. + +- **files:** deps.env (only if the BD_CURRENT_REF early-coverage option is exercised) +- **acceptance:** + - Written verification note (bead comment on the owner bead, not a repo .md) recording: bd release tag, Go module version, all four verbs advertise --if-revision, exit-9 body shape observed, exit-13 body code observed + - Explicit go/no-go: S4 does not proceed past this task on an untagged #4682 + - BD_CURRENT_REF bump (if taken early) is a full 40-char SHA and passes the existing TestBDVersionPins format assertion +- **tests:** + - No new repo tests; this is the gate that arms the rest. The scratch verification commands are recorded verbatim in the bead so the classifier fixtures from S2 can be cross-checked against real output + +### [S4-T1] PR-A: bump the bundled bd pin — deps.env BD_VERSION, SHA table, workflow pins (TDD against TestBDVersionPins) +TDD sequencing: edit deps.env BD_VERSION= FIRST and run `go test ./scripts/` — TestBDVersionPins (scripts/bd_version_pin_test.go) reds naming every stale anchor; then fix each until green. Concretely: (1) deps.env BD_VERSION=; BD_PREV_VERSION stays v1.0.4 (moving it would fire graduation stage 2 — deletion — which is wrong here) so cmd/gc/init_provider_readiness.go bdMinVersion is UNTOUCHED; (2) add SHA-256 pins for to .github/scripts/install-bd-archive.sh for all four tuples (linux_amd64, linux_arm64, darwin_amd64, darwin_arm64) — the test requires `:` entries because BD_VERSION now diverges from BD_PREV_VERSION; (3) update every workflow that pins BD_VERSION (assertWorkflowPins walks .yml/.yaml: ci.yml, nightly.yml, rc-gate.yml, fork-verify.yml, mac-regression.yml, container-scan.yml, ollama-acceptance-c.yml, review-formulas.yml — fix all assignments, not first-hit); (4) bump the contract-matrix cells BD_CURRENT_VERSION/BD_CURRENT_REF to the released tag/commit per the vendored-corpus coordination protocol; (5) check internal/config/config.go bd_compatibility enum — the test only requires the two floor members (bd-1.0.4, bd-1.0.5) which don't move, but if the enum grows a member for it must be consistent. No behavior change in this task; it is pin motion only. + +- **files:** deps.env, .github/scripts/install-bd-archive.sh, .github/workflows/ci.yml, .github/workflows/nightly.yml, .github/workflows/rc-gate.yml, .github/workflows/fork-verify.yml, .github/workflows/mac-regression.yml, .github/workflows/container-scan.yml, .github/workflows/ollama-acceptance-c.yml, .github/workflows/review-formulas.yml +- **acceptance:** + - TestBDVersionPins green with BD_VERSION=, BD_PREV_VERSION=v1.0.4, four SHA tuples present for + - Every .github/workflows/*.y*ml BD_VERSION assignment equals deps.env (assertWorkflowPins clean) + - bdMinVersion (cmd/gc/init_provider_readiness.go) unchanged and still equals 1.0.4 + - CI on the PR actually installs on the required path (install-bd-archive.sh, no API fallback) +- **tests:** + - go test ./scripts/ -run TestBDVersionPins (red before each fix, green after) + - Contract-test matrix jobs (nightly/rc-gate) pass with the new BD_CURRENT_REF cell +- **depends_on:** S4-T0 + +### [S4-T2] PR-A: land the capability version anchor — BD_CONDITIONAL_WRITES_MIN_VERSION + bdConditionalWritesMinVersion under the lockstep +This is the interim→tagged transition's first half: the anchor. Add BD_CONDITIONAL_WRITES_MIN_VERSION= to deps.env (the floor is exactly the first release carrying #4682) and the mirroring Go constant `const bdConditionalWritesMinVersion = ""` in internal/beads (beside the probe code S2 landed; precedent shape: bdReadyProjectionMinVersion = "1.0.5" in internal/beads/bdstore_ready_projection.go:10). Extend TestBDVersionPins in scripts/bd_version_pin_test.go with the same lockstep assertions the ready-projection floor gets: (a) the Go const equals the deps.env value (use the existing extractGoStringConst helper); (b) the floor is strictly newer than bdMinVersion via deps.CompareVersions (a CAS floor at or below the init floor gates nothing); (c) BD_VERSION >= the floor (the bump PR that lands the anchor must satisfy it, otherwise the anchor is aspirational). Landing this key is what ARMS the two-stage TestConditionalWritesGraduation test that S1 shipped (it reads the key via readDotenv and no-ops while the key is absent) — which is why T3 and T4 must ride the same PR. + +- **files:** deps.env, internal/beads/bdstore_conditional.go (or wherever S2 placed the probe; the const may get its own conditional_writes_version.go), scripts/bd_version_pin_test.go +- **acceptance:** + - deps.env has BD_CONDITIONAL_WRITES_MIN_VERSION=; internal/beads has bdConditionalWritesMinVersion in lockstep, enforced by new TestBDVersionPins assertions + - New assertions are TDD'd: written first against the old deps.env (must fail 'missing key'), then the key lands + - deps.CompareVersions ordering assertions (floor > bdMin, BD_VERSION >= floor) pass + - S1's TestConditionalWritesGraduation transitions from dormant (key absent) to armed in this PR — verified by running it before and after the deps.env edit +- **tests:** + - go test ./scripts/ -run 'TestBDVersionPins|TestConditionalWritesGraduation' + - Unit test asserting the graduation test's dormant path (key absent => skip/pass) still works on a synthetic dotenv, so a revert of the key can't strand CI red +- **depends_on:** S4-T1 + +### [S4-T3] PR-A: switch the BdStore capability probe from four-verb help-grep to ProbeBDVersion + CompareVersions; latch stays authoritative +Second half of the transition (DESIGN §8.3: 'Help-grep is the interim detector only'). Rework conditionalWritesCapable() in internal/beads so the memoized probe verdict is `deps.CompareVersions(version, bdConditionalWritesMinVersion) >= 0`, where version comes through the store's EXISTING injected CommandRunner (`s.runner(s.dir, "bd", "version")` — the exact bdReadyProjectionEnabled shape at bdstore_ready_projection.go:69-88; do NOT call the package-level ProbeBDVersion() from binary_versions.go directly, it bypasses the single seam and makes the probe untestable via the fake runner — reuse its parseBDVersion parsing only). Delete the four-verb --help loop. Semantics preserved exactly: lazy (first conditional write, never construction), memoized under condWriteMu, probe error or unparseable version => incapable (loud degrade under auto, typed refusal under require — never a crash), and the runtime exit-13/unknown-flag latch remains AUTHORITATIVE over the probe in both skew directions (in-place downgrade after a capable probe latches incapable; in-place upgrade after an incapable probe stays incapable until restart). Update doctor/status Reason strings from help-text wording to version wording ('bd 1.1.0 < conditional-writes floor 1.2.0') — these strings are asserted in S1's doctor tests and in the T9 status-wire tests, so grep for the old wording (monitor-sweep gotcha: use grep -F). + +- **files:** internal/beads/bdstore_conditional.go (S2's probe file), internal/beads/bdstore_conditional_internal_test.go, internal/beads/binary_versions.go (export/reuse parseBDVersion if needed) +- **acceptance:** + - No bytes.Contains(out, "--if-revision") probe remains in internal/beads (the classifier's unknown-flag RUNTIME detection is untouched — only the probe changes) + - Probe verdict flows through the one CommandRunner seam; no new injection point added (no WithBDCapabilityProbe) + - Latch-over-probe precedence unchanged: a tripped latch short-circuits before the probe in both directions + - Probe failure modes (runner error, gibberish version output) yield incapable + a Reason string, never an error that aborts the write path +- **tests:** + - Rewrite the S2 probe unit tests: fake runner returns version tokens below/at/above the floor => incapable/capable/capable + - Fake runner returns unparseable output => incapable with reason + - Capable version probe + subsequent exit-13 body-coded write => latch trips, next write reports latched (probe not re-consulted) + - Incapable version probe => --if-revision never reaches the runner (argv assertion on the fake) + - Race check: go test -race on the probe memoization (two goroutines, first conditional write) +- **depends_on:** S4-T2 + +### [S4-T4] PR-A: satisfy graduation stage 1 — set FlipDueBy= (bounded deferral), do NOT flip Off→Auto in the bump PR +The moment T2 lands the anchor with BD_VERSION >= floor, TestConditionalWritesGraduation stage 1 fires: 'flip default Off→Auto and set GraduatedIn, or set FlipDueBy'. Per DESIGN §11.2 the bump PR takes the one bounded escape: edit internal/rollout/registry.go setting the beads.conditional_writes Spec's FlipDueBy= (the BD_VERSION being landed). Rationale to state in the PR body: the Off→Auto flip changes live epoch-fence/drain behavior fleet-wide and the design's own runbook (§12.6) forbids Require-adjacent motion on the deployed sqlite topology before the S3 integration test has soaked — the flip gets its own dedicated PR (S4-T12). Properties to preserve: the deferral is diff-visible in a CODEOWNERS-gated file (registry.go is owned by @gastownhall/gascity-admins per S1), holds only while BD_VERSION <= FlipDueBy (the NEXT anchor bump reds again), and the nightly radar files a pending-flip bead against Owner.Bead while it stands. PR-A is now complete and independently mergeable: pins moved, anchor landed, probe transitioned, lifecycle debt visible — zero wire change, zero behavior change at mode=off (the shipped default). + +- **files:** internal/rollout/registry.go, internal/rollout/registry_test.go (only if the FlipDueBy grammar assertion needs the new value class) +- **acceptance:** + - TestConditionalWritesGraduation green with FlipDueBy= set and Default still Off + - registry.go diff reviewed by a named human (CODEOWNERS enforced) + - Radar (scripts/rolloutradar) run locally files/updates the pending-flip finding against Owner.Bead + - PR-A as a whole: make test green, go vet clean, no change to any resolved mode anywhere (Default=Off, no config samples touched) +- **tests:** + - go test ./scripts/ -run TestConditionalWritesGraduation (red without FlipDueBy, green with) + - registry_test.go per-category rules still green (Expires/VersionAnchor mandatory fields intact) + - Radar unit test: FlipDueBy-pending finding class emitted +- **depends_on:** S4-T3 + +### [S4-T5] PR-B: add Revision to the domain Bead and plumb it through every store (test-first via the conformance suite) +TDD anchor: extend the S2 storage conformance suite (internal/beads/bdstore_storage_conformance_test.go) FIRST with the revision-visibility contract — every mutation (Update, Close, Assign, delete-adjacent metadata writes, label edits, CompareAndSetMetadataKey) bumps Bead.Revision; reads never do; Get after a mutation shows a strictly greater value — then make it pass per store. Field: `Revision int64` with json:"revision,omitempty" on internal/beads/beads.go's Bead (line ~50, after IsBlocked). omitempty is deliberate: a pre-#4682 bd emits no revision, decode yields 0, and the wire must not advertise a validator the store cannot honor (see T8's ETag rule). Per store: BdStore — decode revision from bd's JSON (bd show/list --json; tolerate absence exactly like the StringMap-tolerant metadata decode — a missing field must never poison a batch); MemStore/File stores — increment on every mutation, start at 1 on create; CachingStore — propagate on read-through, bump-consistent on write-through, and verify the S2 evict-on-PreconditionFailed discipline now ALSO proves 'no stale Revision served after a 412' (this is the C2 never-return-a-stale-ETag obligation, DESIGN §9.5); native_dolt_store.go — map beadslib.Issue.Revision in nativeIssueFromBead (:1404) and beadFromNativeIssue (:1467); sqlite store (deploy lineage, new-file-only rule) — reads stamp Bead.Revision from the revision column added in S3's migration (column authoritative, bead_json never carries it — DESIGN §10). Retire the S2 workaround where the conformance suite read Current off PreconditionFailedError as the revision oracle; the field is now first-class. + +- **files:** internal/beads/beads.go, internal/beads/bdstore.go (decode paths), internal/beads/memstore.go, internal/beads/caching_store_reads.go, internal/beads/caching_store_writes.go, internal/beads/native_dolt_store.go, internal/beads/bdstore_storage_conformance_test.go, internal/beads/sqlite_store_conditional.go (deploy-lineage revision read, if S3 left it pending) +- **acceptance:** + - Conformance contract 'mutations bump, reads don't' passes for Mem, File, Bd(fake-runner), Caching-over-each; sqlite variant of the same test compiles against the deploy-lineage store shape (guarded so main, which has no SQLiteStore, still builds — same mechanism S3 chose) + - BdStore decode of a bead JSON without revision yields 0 and does not error (regression test with a real pre-#4682 corpus fixture) + - CachingStore: after an injected PreconditionFailed, the next Get re-reads and serves the current Revision (stale-ETag regression test) + - No wire/API change yet in this task — internal/beads only (keeps the commit reviewable before the regen tax) +- **tests:** + - internal/beads conformance suite extension (test-first, red on all stores initially) + - internal/beads/bdstore_internal_test.go decode-tolerance cases + - caching_store revision-eviction test beside caching_store_writes_internal_test.go + - native_dolt_store round-trip test asserting Issue.Revision <-> Bead.Revision (unit, no live Dolt) +- **depends_on:** S4-T0 + +### [S4-T6] PR-B: bump go.mod github.com/steveyegge/beads and absorb library API drift +`go get github.com/steveyegge/beads@ && go mod tidy`. The blast radius is confined by construction: beadslib is imported only from internal/beads/native_dolt_store.go and its tests (verified by grep), so API drift lands in one file. Absorb any Issue/IssueFilter/storage-surface changes the #4682 release carries; if the library grew its own ConditionalWriter surface, note it in the S2 interface's doc comment but do NOT swap the BdStore subprocess transport for library calls in this stage (that is a separate design decision; the flag gates transports, and changing transport mid-bump doubles the review surface). This task is sequenced AFTER T5 so the T5 conformance suite acts as the regression harness for the bump: if the new library changes Issue semantics, the round-trip tests red here, not in production. Check transitive go.mod churn for license/size surprises; run the full fast shards (not raw `go test ./...` — TESTING.md; and note the memory gotcha that `go test ./cmd/gc/` alone times out at 600s, use the sharded targets). + +- **files:** go.mod, go.sum, internal/beads/native_dolt_store.go (drift absorption only) +- **acceptance:** + - go.mod/go.sum bumped; go build ./... green; go vet ./... clean + - internal/beads native-store tests green against the new library, including the T5 Revision round-trip + - No new direct beadslib import outside internal/beads (boundary_test.go still green) + - make test-fast-parallel green +- **tests:** + - Existing native_dolt_store_test.go + T5's round-trip under the new version + - go vet + the import-boundary tests (internal/rollout zero-beads-imports test from S1 must still pass untouched) +- **depends_on:** S4-T5 + +### [S4-T7] PR-B: regenerate the full spec artifact chain — genspec, genclient, three tracked OpenAPI copies, events schemas +The moment T5's Revision field exists on internal/beads.Bead, every response type embedding it (BeadGraphResponse at internal/api/handler_beads.go:374-376, the single-bead GET, list/children/deps outputs in huma_types_beads.go, and any events payload embedding Bead) changes schema, and TestOpenAPISpecInSync (internal/api/openapi_sync_test.go) reds. Run the exact CI recipe: `make spec-ci` => go run ./cmd/genspec + go generate ./internal/api/genclient, then commit internal/api/openapi.json, docs/reference/schema/openapi.json, docs/reference/schema/openapi.txt, docs/reference/schema/events.json, docs/reference/schema/events.txt, internal/api/genclient/client_gen.go together. Review the diff deliberately, not as noise: `revision` must appear as integer/int64 with the omitempty optionality on every Bead-bearing schema, and NOTHING ELSE may drift — an unexpected schema delta here means the library bump leaked a type change through internal/beads and must be triaged before merge. This is also where T8/T9's additions (If-Match params, 412/422/501 responses, the status-wire struct) will regen AGAIN — so in commit order this task runs twice: once after T5/T6 (bead field only) to isolate the field diff, once after T9 to fold in the handler surface. Keep them as separate commits inside PR-B for reviewability. + +- **files:** internal/api/openapi.json, docs/reference/schema/openapi.json, docs/reference/schema/openapi.txt, docs/reference/schema/events.json, docs/reference/schema/events.txt, internal/api/genclient/client_gen.go +- **acceptance:** + - make spec-ci exits 0 with no residual git diff on the six tracked artifacts + - openapi.json diff shows revision on every Bead schema occurrence and no unexplained drift + - TestOpenAPISpecInSync green; genclient compiles and its genclient_test.go passes + - Two isolated regen commits in PR-B: field-only, then handler-surface +- **tests:** + - make spec-ci (the drift gate IS the test) + - go test ./internal/api/ -run TestOpenAPISpecInSync + - go test ./internal/api/genclient/ +- **depends_on:** S4-T6 + +### [S4-T8] PR-B: C2 handler surface — parseStrongETag, conditionalBeadWrite helper, If-Match on the four mutations, ETag out +Test-first handler work in internal/api. (1) parseStrongETag: exactly one strong ETag, quoted decimal; weak validators (W/"..."), lists, and * => Huma 422 (table-test all rejects). (2) One shared helper `conditionalBeadWrite` per the DESIGN §9.5 sketch — resolves via the S1/S2 seam (beads.ResolveConditionalWriter(store), the thin consumer-owned adapter; the API layer never touches rollout internals) and takes legacy/conditional closures so per-endpoint logic cannot fork. (3) If-Match as a typed Huma header param on BeadUpdateInput (huma_types_beads.go:84), BeadCloseInput (:72), BeadAssignInput (:137), BeadDeleteInput (:146); handlers humaHandleBeadUpdate (huma_handlers_beads.go:613), humaHandleBeadClose (:523), humaHandleBeadAssign (:571), humaHandleBeadDelete (:685) route through the helper. BeadReopenInput (:78) is deliberately excluded to match the design's four verbs — record that as an explicit line in the PR body so it is a decision, not an omission. (4) The four-row semantics: no If-Match => byte-identical legacy path (golden test); If-Match + active => *IfMatch store write, 2xx with fresh ETag header, PreconditionFailedError => 412 with the registered precondition_failed problem body carrying expected_revision/current_revision; If-Match + inactive (off, or store incapable) => 501 conditional_writes_unsupported naming mode/verdict/origin — never a silent unconditional write. (5) ETag out: single-bead GET (BeadGetInput :42) and 2xx mutation responses set ETag to the quoted decimal revision — EMIT ONLY WHEN Revision > 0 (deliberate, documented refinement of the design's 'every GET' line: a store that doesn't track revisions must not hand clients a validator that can only ever 501; body omitempty and header-absence agree). (6) Problem types: register precondition_failed and conditional_writes_unsupported through whatever registered-error mechanism is on main at merge time — internal/api/openapi_problem_types.go constants today, or the apierr registry if PR #4103 has landed (its guard forbids raw urn:gascity:error literals outside internal/api/apierr; check before writing constants) — and declare 412/422/501 on the Huma operations so they appear in the regenerated spec. + +- **files:** internal/api/huma_types_beads.go, internal/api/huma_handlers_beads.go, internal/api/handler_beads_conditional.go (new: parseStrongETag + conditionalBeadWrite), internal/api/openapi_problem_types.go (or internal/api/apierr/ if #4103 landed), internal/api/handler_beads_conditional_test.go (new) +- **acceptance:** + - Handler-level table tests cover all four DESIGN §9.5 rows for all four mutation verbs (16 cells minimum) plus the parseStrongETag reject table + - 412 round-trip test: mutation with stale If-Match => 412 body has expected_revision/current_revision; follow-up GET's ETag equals current_revision (the CachingStore eviction from T5 is what makes this pass) + - No-If-Match requests are byte-identical to pre-S4 behavior (recorded-response golden test) + - 501 (not 412) on mode=off/incapable so client retry loops terminate; body names mode, verdict, origin + - ETag emitted iff Revision > 0; header and body always agree when both present + - No map[string]any / hand-written JSON anywhere on the path (typed-wire invariant) +- **tests:** + - internal/api/handler_beads_conditional_test.go (new): the 4x4 table, parseStrongETag table, 412 round-trip, golden legacy-equivalence + - internal/api tests run against a fake store implementing ConditionalWriter and one that doesn't (both verdict branches real) + - supervisor_nonhuma_guard_test.go and the mutation-403-declaration guard stay green (new responses declared, not smuggled) +- **depends_on:** S4-T7 + +### [S4-T9] PR-B: status wire — BeadsConditionalWritesStatus on GET /v0/status + live-API doctor rendering +The typed observability surface rides this PR because the spec-regen tax is already paid (DESIGN §12.5). Add to internal/api: BeadsConditionalWritesStatus {Mode, Origin, Effective enum off/active/degraded/fail_closed/pending_restart, Stores []ConditionalWriteStoreVerdict, Notices []RolloutNotice} and ConditionalWriteStoreVerdict {StoreID, Kind enum bd/native/sqlite-graph/caching/mem/file, Probe, Latch, Capable, Reason} — Huma-registered fields on StatusBody (internal/api/huma_types_patches.go:156, served by handler_status.go's humaHandleStatus). Population is the daemon's OWN latched snapshot, never a re-derivation: boot-resolved rollout Flags (S1's controllerState-held value, including retained Notices — env_overrides_config, pending_restart) plus each resolved store's real probe/latch pair read through a small read-only accessor on the S2 store surface (probe and latch stay SEPARATE columns; collapsing them was explicitly rejected). Then flip gc doctor's rollout section to live-API mode: when the city is up, query /v0/status and render the snapshot verbatim (probe AND latch columns now both real); local re-resolution with the S1 banner becomes the stopped-city fallback only. Dashboard renders nothing new in this stage (types exist via T10's regen; a Rollout panel is future work — do not gold-plate). + +- **files:** internal/api/huma_types_patches.go, internal/api/handler_status.go, internal/api/handler_status_test.go, cmd/gc/doctor_rollout.go (S1's doctor section, gaining live-API mode), internal/api/openapi.json + tracked copies (regen) +- **acceptance:** + - GET /v0/status carries beads_conditional_writes with the per-store verdict array; spec regenerated (second T7 pass) and TestOpenAPISpecInSync green + - Boundary test (from DESIGN §12.4): daemon started with GC_BEADS_CONDITIONAL_WRITES contradicting a temp city.toml => status response contains the env_overrides_config notice verbatim + - Effective value is a pure function of (mode, per-store verdicts) with a unit-tested truth table including the mixed-fleet case (one capable + one latched-incapable store => degraded, not active) + - gc doctor against a running city renders the API snapshot (latch column real); against a stopped city falls back to local re-resolution with the banner + - Every KnownEventTypes/payload invariant untouched (no new events in this task) +- **tests:** + - internal/api/handler_status_test.go additions: snapshot rendering, notice passthrough, effective truth table + - doctor integration test: live-mode vs fallback-mode selection + - go test ./internal/api/dashboardspa/... unaffected pre-T10 +- **depends_on:** S4-T8 + +### [S4-T10] PR-B: restore dashboard TS generation (ga-iialk6) and regenerate the gc-supervisor client with revision + status types +The dashboard's generated client (internal/api/dashboardspa/web/shared/src/generated/gc-supervisor-client/{types,sdk,zod,client}.gen.ts) has @hey-api/openapi-ts ^0.97.3 in the root package.json devDependencies but NO regen config or npm script — lost in the #3727 move (memory: dashboard-openapi-ts-config-lost; tracked bead ga-iialk6). Hand-editing 'auto-generated' files is forbidden, so restoration is a blocking sub-deliverable: (1) add openapi-ts.config.ts in web/shared pointing at internal/api/openapi.json with output to src/generated/gc-supervisor-client and the plugins matching the existing four .gen.ts shapes (types, sdk, zod, fetch client); (2) add a `generate` script to the shared workspace's package.json; (3) regenerate — expect a LARGE first diff because the checked-in types have drifted since #3727; isolate the restoration+regen as its own commit and triage the drift (any drift beyond revision/status additions is pre-existing staleness being paid down, name it in the PR body); (4) verify the frontend compiles against the refreshed types (the Bead type at types.gen.ts:271 gains revision?: number) and fix any narrow type breaks; (5) add the missing drift gate so this never rots again: a step in make dashboard-check (or a small go test in dashboardspa) that runs the generator and fails on git diff of the generated dir — the exact spec-ci pattern, closing the asymmetry with genclient. + +- **files:** internal/api/dashboardspa/web/shared/openapi-ts.config.ts (new), internal/api/dashboardspa/web/shared/package.json, internal/api/dashboardspa/web/shared/src/generated/gc-supervisor-client/*.gen.ts, internal/api/dashboardspa/web/package.json (workspace script plumbing), Makefile (dashboard-check drift step), internal/api/dashboardspa/dist/ (rebuilt) +- **acceptance:** + - npm run generate (shared workspace) reproduces the checked-in .gen.ts files byte-for-byte from internal/api/openapi.json + - types.gen.ts Bead carries revision; BeadsConditionalWritesStatus/ConditionalWriteStoreVerdict types present + - make dashboard-check green (typecheck + SPA build + dashboardspa/dashboardbff go tests); make dashboard-ci green (embedded dist not stale) + - New drift gate red-tested: touch a .gen.ts by hand, gate fails; regen, gate passes + - ga-iialk6 closeable +- **tests:** + - make dashboard-check && make dashboard-ci + - the new generated-dir drift gate itself + - npm run preview -- --host 127.0.0.1 --port smoke per the quality-gate checklist (dashboard serves) +- **depends_on:** S4-T9 + +### [S4-T11] PR-B close-out: full quality gates, runbook refresh, PR assembly +Assemble PR-B as an atomic, independently mergeable unit against gascity origin/main: commits ordered (Revision field + stores) -> (go.mod bump) -> (regen #1) -> (C2 handlers) -> (status wire) -> (regen #2) -> (dashboard restore+regen). Gates, all of them, because the pre-commit hook skips package guards (memory: precommit-hook-skips-package-guards): make test-fast-parallel; targeted go test ./internal/beads/ ./internal/api/ ./scripts/ ./internal/rollout/; go vet ./...; make spec-ci; make dashboard-check + dashboard-ci; the testenv-import pre-push guard if any new test package appeared (go run scripts/add-testenv-import.go). Docs: update the DESIGN §12.6 runbook entries now that the status wire is live (doctor live-API is primary), and the engdocs feature-flags plan's stage tracker. Explicitly re-verify the S1 import-boundary test one more time on the final tree: internal/rollout still imports zero beads packages (this stage touched beads heavily; the boundary is the acceptance condition). Session-completion protocol applies: push, verify 'up to date with origin'. + +- **files:** engdocs/plans/feature-flags/DESIGN.md (stage tracker/runbook deltas) +- **acceptance:** + - All listed gates green on the assembled branch; no gate waived + - PR-B body documents: the two-knob bump mapping (bd tag vs module version), the reopen-exclusion decision, the ETag-iff-revision>0 refinement, the dashboard drift paydown + - internal/rollout import-boundary test green on the final tree + - PR-A and PR-B each mergeable to origin/main independently (PR-B does not require PR-A merged first for compilation — capability just probes incapable against an old bd until PR-A's pin lands in CI images; verify by running PR-B's branch with the OLD bd pinned) +- **tests:** + - The union of every test named in T5-T10, run on the assembled branch + - Cross-order check: PR-B branch with old BD_VERSION in CI => all green with probe=incapable (proves no hidden ordering coupling) +- **depends_on:** S4-T10, S4-T4 + +### [S4-T12] Gated follow-up PR: the Off→Auto default flip (graduation stage 1 execution) +The dedicated, soaked flip PR that T4's FlipDueBy deferred. Preconditions, all verifiable: PR-A and PR-B merged; the S3 sqlite CompareAndSetMetadataKey integration test has run against the deployed store shape (deploy/sqlite-b36-probe-attribution) and an auto-mode soak window on a real city shows zero unexpected degraded/refusal events (grep the events log for the S2 degraded event type, excluding sudo[] audit lines per the monitor-sweep gotcha); doctor's multi-writer warning (auto + DEGRADED + declared multi-writer topology) verified firing in a synthetic test. The edit itself is small and loud: registry.go Default Off->Auto, set GraduatedIn=, clear FlipDueBy; registry_test's Default-equals-zero-value-accessor assertion updates in lockstep (the S1 test that catches half-landed graduations — this PR is exactly the case it exists for: the config accessor's zero-value semantics must move with the registry Default or CI reds). CODEOWNERS review required. This task may trail the rest of S4 without blocking stage exit — the radar files pending-flip debt against the Owner while it waits, and the NEXT BD_VERSION bump reds CI if it is forgotten (FlipDueBy is one-bump-bounded). It is listed here so the stage plan owns the commitment rather than orphaning it. + +- **files:** internal/rollout/registry.go, internal/rollout/registry_test.go, internal/config config accessor zero-value default test (BeadsConfig.ConditionalWritesMode) +- **acceptance:** + - Default=Auto, GraduatedIn=, FlipDueBy cleared, in one registry.go diff with named-human review + - registry_test zero-value-config-equals-Default assertion updated in the same PR and green + - Soak evidence linked in the PR body (events-log query, doctor output from the deployed topology) + - TestConditionalWritesGraduation stage 1 permanently satisfied; stage 2 (deletion) still dormant (BD_PREV_VERSION=v1.0.4 < floor) +- **tests:** + - go test ./internal/rollout/ ./scripts/ + - One end-to-end auto-mode test: zero-config city resolves Auto, capable store gets conditional writes, incapable store loud-degrades (the S1/S2 machinery under the new default) +- **depends_on:** S4-T11 + +**Exit criteria:** PR-A merged: deps.env BD_VERSION at the first #4682-carrying release with four-tuple SHA pins and all workflow pins in lockstep (TestBDVersionPins green); BD_PREV_VERSION and bdMinVersion untouched · Capability detection is version-anchored: BD_CONDITIONAL_WRITES_MIN_VERSION in deps.env mirrored by internal/beads bdConditionalWritesMinVersion under new TestBDVersionPins assertions; the four-verb help-grep probe is deleted; the runtime exit-13 latch remains authoritative in both skew directions · TestConditionalWritesGraduation is armed and green with either FlipDueBy= visibly set (T4) or the flip executed (T12); no state exists where the anchor is past the floor, Default is Off, and CI is green without a reviewed deferral · PR-B merged atomically: go.mod beads library bumped; Bead.Revision on internal/beads.Bead and populated by Mem/File/Bd/Caching/native-Dolt (and the deploy-lineage sqlite read path); conformance suite proves every mutation bumps and reads never do · revision present on every Bead-bearing schema in all six tracked spec artifacts (internal/api/openapi.json, docs/reference/schema/openapi.{json,txt}, events.{json,txt}, genclient/client_gen.go); make spec-ci green with zero residual drift · C2 live: If-Match on update/close/assign/delete with the four-row semantics (legacy byte-identical without If-Match; 412 with expected/current revision; 501 typed refusal on off/incapable — never a silent unconditional write under a presented precondition); ETag emitted iff Revision > 0 and never stale after a 412 · GET /v0/status carries the typed beads_conditional_writes aggregate with per-store probe/latch verdicts and retained notices; gc doctor renders the live snapshot when the city is up · Dashboard TS generation restored with a drift gate (ga-iialk6 closed); make dashboard-check and dashboard-ci green with regenerated types carrying revision and the status structs · internal/rollout import-boundary test (zero beads imports) green on the final tree; no new GC_* env read outside the S1 baseline + +_General-Auto: S4 touches internal/rollout in exactly two places — the FlipDueBy field edit (T4) and the eventual Default flip (T12) — both pure registry-data edits on the beads Spec, zero resolver-logic changes; the general Mode/Capability resolver and its zero-beads-imports boundary test are untouched and re-verified on the final tree (T11). The interim-to-tagged capability transition (help-grep to ProbeBDVersion + version anchor) lives entirely inside internal/beads, i.e., inside the consumer-owned Capability predicate that CAS supplied in S2 — proving the intended shape: consumers evolve their own capability detection arbitrarily (help text today, version compare tomorrow, interface assertion for sqlite) while rollout.Resolve keeps computing enable-AND-capable generically. The status wire is likewise layered general-to-specific: RolloutNotice/Origin come from internal/rollout unchanged and would serialize identically for daemon.formula_v2 or any future flag, while BeadsConditionalWritesStatus and the per-store verdict array are consumer-shaped types living in internal/api beside their consumer — nothing beads-flavored leaks into the general package, and a second flag wanting a status block adds its own aggregate without touching rollout. The version-anchor machinery itself (deps.env key + Go const + TestBDVersionPins lockstep + two-stage graduation) is per the design deliberately NOT generalized into a predicate DSL (rejected at N=1, DESIGN 14.1.4); the second version-anchored flag will extract a shared helper if and when it exists._ + +## S5-formulav2 — Migrate daemon.formula_v2 fully onto internal/rollout and delete the global-setter anti-pattern — cmd/gc/feature_flags.go and its 7 call sites, internal/api syncFeatureFlags, the formulaV2Enabled/graphApplyEnabled atomic.Bool pair, the formulatest.LockV2ForTest process-wide mutex, and the ~21 save/restore blocks in internal/molecule tests — replacing them with the boot-resolved rollout.Flags value threaded by DI through option structs and explicit parameters. Also absorb the two legacy env one-offs (GC_DOLT_AUTO_GC_ENABLED, GC_EVENTS_ROTATION_ENABLED) as infra-killswitch Specs with their shipped precedence preserved, and execute the daemon.graph_workflows tombstone. This is the committed same-milestone blocking bead that proves the registry at N=2 (design §13, DESIGN.md lines 2064-2191, 2408). + +### [S5-T1] Verify/complete the rollout surface S5 consumes: Flags.FormulaV2(), WithFormulaV2 ForTest option, default-equality tooth +S1 registered the daemon.formula_v2 Spec (Category InfraMigration, ConfigPath daemon.formula_v2, EnvOverride "", Default Bool=true — DESIGN.md §13.2, lines 2104-2118). This task confirms and, where S1 left gaps, completes the read surface stage 5 depends on, all in internal/rollout: (a) typed accessor Flags.FormulaV2() bool whose value Resolve derives from config.DaemonConfig.FormulaV2Enabled() semantics (nil *bool ⇒ true, internal/config/config.go:4272-4297) with NO env leg (EnvOverride is empty; none is added — design line 2110); (b) rollout.ForTest(t, rollout.WithFormulaV2(false)) typed option so deleting the flag later breaks tests at compile time; (c) the registry default-equality subtest: Resolve over a zero-value config.City with empty injected LookupEnv yields FormulaV2()==true==Spec.Default (the two-homes drift closer, design §3.6.3). No capability predicate is supplied for this flag — the resolver's capability leg must be provably vacuous for predicate-less flags (enable alone decides). The existing import-boundary test (internal/rollout imports zero beads packages) must remain untouched and green. + +- **files:** internal/rollout/flags.go, internal/rollout/registry.go, internal/rollout/registry_test.go, internal/rollout/fortest.go +- **acceptance:** + - Flags.FormulaV2() bool exists and is derived solely from the merged config field (daemon.formula_v2 *bool, nil ⇒ true); no GC_* env var participates + - rollout.WithFormulaV2(bool) is a typed ForTest option; no string-keyed override path exists for this flag + - Zero-value-config Resolve returns FormulaV2()==true and registry_test asserts equality with Spec.Default for Key daemon.formula_v2 + - A predicate-less Spec resolves without invoking any capability machinery (unit-tested: capability hook never called when Spec supplies none) + - internal/rollout still has zero internal/beads imports (existing boundary test green, no edits to it) +- **tests:** + - registry_test.go subtest: default-equality for daemon.formula_v2 against zero-value config.City + - flags_test.go: two t.Parallel tests build ForTest Flags with opposite WithFormulaV2 values and assert isolation (no process-scoped state) + - resolve_test.go: predicate-less flag never triggers capability evaluation; Off/Require-equivalent semantics for *bool kind (false/true) + +### [S5-T2] Thread the boot-resolved formula_v2 value through the option-struct seams (plumbing only; leaves still read the globals) +Add the DI vehicle before flipping any consumer. (1) internal/molecule/molecule.go:23 Options and the FragmentOptions struct gain `FormulaV2 *bool` (nil ⇒ registry default true) plus an unexported helper `formulaV2Enabled() bool`; *bool is deliberate: cmd/gc/order_dispatch.go:1500 and cmd/gc/cmd_order.go:752 construct `molecule.Options{}` zero-valued, and a plain bool zero value would silently flip the default transport from graph-apply to sequential v1 — nil-⇒-default mirrors the shipped DaemonConfig.FormulaV2 *bool idiom and makes a forgotten call site preserve today's behavior instead of changing it. This threads sling for free: sling.InstantiateSlingFormula (internal/sling/sling.go:1262) and validateSlingFormulaRuntimeVars (sling_core.go:1056) already take molecule.Options. (2) internal/dispatch ProcessOptions (runtime.go:36) and the opts structs reaching fanout.go:135/162, drain.go:1035/1052, and ralph.go:415-417 gain the same field. (3) internal/graphv2/invocation.go call paths (:244/:250) receive it via parameter/options. (4) Composition roots populate it from the boot-resolved Flags: cmd/gc commands from the Flags produced by loadCityConfig*/loadCityConfigWithBuiltinPacks (cmd/gc/cmd_agent.go:32-79, cmd/gc/cmd_config.go:25 — S1 wired Resolve there); internal/api handlers (handler_formulas.go:187/279, sling handler) from the server State's boot-resolved Flags (S1). No leaf behavior changes in this task: internal/formula and internal/molecule still read their atomics, so the diff is byte-identical at runtime. Keep the PR reviewable: option-struct fields + root population, with propagation asserted by tests. + +- **files:** internal/molecule/molecule.go, internal/dispatch/runtime.go, internal/dispatch/fanout.go, internal/dispatch/drain.go, internal/dispatch/ralph.go, internal/graphv2/invocation.go, internal/api/handler_formulas.go, cmd/gc/cmd_formula.go, cmd/gc/order_dispatch.go, cmd/gc/cmd_order.go +- **acceptance:** + - molecule.Options and FragmentOptions carry FormulaV2 *bool with nil ⇒ true, documented as equal to the daemon.formula_v2 Spec default + - Every production construction site of molecule.Options / dispatch ProcessOptions on a formula-execution path sets the field from boot-resolved Flags (inventory in the PR description; cmd/gc sites: cmd_formula.go:711, order_dispatch.go:1500, cmd_order.go:752, plus sling/dispatch feeders) + - internal/api handler paths source the value from the State-carried boot Flags, never by re-resolving config + - off/legacy behavior is byte-identical: full existing suite green with zero test edits in leaf packages + - No leaf package (formula, molecule) consumes the new field yet — globals remain authoritative (verified by unchanged reads at compile.go:55, fragment.go:27, molecule.go:475/784, ralph.go:416) +- **tests:** + - molecule: unit test nil-FormulaV2 ⇒ formulaV2Enabled()==true; explicit false ⇒ false (written first, red until helper exists) + - dispatch: ProcessOptions propagation test — value set at the root arrives at the fanout/drain/ralph opts capture point + - cmd/gc entry-point test (routeReadCmd lesson): a command run with a city.toml fixture containing [daemon] formula_v2=false produces Options.FormulaV2 pointing at false at the molecule seam (stub/capture) + - api: handler test constructs a server State with rollout.ForTest(t, rollout.WithFormulaV2(false)) and asserts the handler passes it through +- **depends_on:** S5-T1 + +### [S5-T3] Formula flip: explicit v2Enabled parameter through compileFormula and the three public entry points; formula/graphv2/graphroute/sling tests migrate off the global +The design-pinned shape (DESIGN.md line 2126): explicit parameter, matching the existing ValidateHostRequirements(f *Formula, formulaV2Enabled bool) at internal/formula/requirements.go:76. Change: compileFormula(name, searchPaths, vars, validateRuntimeVars, v2Enabled bool); public entry points Compile (compile.go:37), CompileWithoutRuntimeVarValidation (compile.go:47), CompileExpansionFragment (fragment.go:25) each gain a trailing v2Enabled bool. Delete the atomic reads at compile.go:55 and fragment.go:27. The compile-time signature break is the removal mechanism — it finds all 16 production callers, each of which now passes its T2 seam value: sling.go:1268 + sling_core.go:253/1038/1057 (from molecule.Options.formulaV2Enabled()), molecule.go:142 (opts helper), dispatch fanout.go:135 + drain.go:1035 (ProcessOptions), graphv2 invocation.go:244/250, api handler_formulas.go:187/279 (State Flags), cmd/gc cmd_formula.go:132/665/758/1164 + order_dispatch.go:1394 (loadCityConfig-resolved Flags). SetFormulaV2Enabled/IsFormulaV2Enabled remain DEFINED but unread by any compile path (still called by feature_flags.go/syncFeatureFlags/formulatest until S5-T5) — the setter becoming inert makes any missed test migration fail LOUDLY in the v2=false direction (tests forcing false to assert rejection break; tests forcing true match the default no-op). Migrate in the same PR every test whose flag control crossed the compile boundary: internal/formula compile_test.go (14 refs), requirements_test.go (22), graphv2_validation_test.go (14), fragment_test.go (2), testhelper_test.go (2); internal/graphroute/graphroute_test.go (:331/:454); internal/graphv2/invocation_test.go and internal/sling/sling_test.go (formulatest importers). Migrated tests pass explicit params and gain t.Parallel. This is a wide mechanical PR — execute with worktree-isolated subagents (never in-tree mutation on the shared checkout) and land quickly to limit rebase pain. + +- **files:** internal/formula/compile.go, internal/formula/fragment.go, internal/formula/compile_test.go, internal/formula/requirements_test.go, internal/formula/graphv2_validation_test.go, internal/formula/fragment_test.go, internal/formula/testhelper_test.go, internal/sling/sling.go, internal/sling/sling_core.go, internal/molecule/molecule.go, internal/dispatch/fanout.go, internal/dispatch/drain.go, internal/graphv2/invocation.go, internal/api/handler_formulas.go, cmd/gc/cmd_formula.go, cmd/gc/order_dispatch.go, internal/graphroute/graphroute_test.go, internal/graphv2/invocation_test.go, internal/sling/sling_test.go +- **acceptance:** + - No read of IsFormulaV2Enabled() remains anywhere except cmd/gc/feature_flags.go, internal/api/server.go:231, and internal/formulatest/v2.go (the T5 deletion targets) — grep-verified in the PR + - All three public compile entry points require v2Enabled explicitly; there is no defaulted/variadic escape hatch + - Compile-rejection contract preserved: a graph-construct formula with v2Enabled=false fails with the existing 'requires formula compiler v2 but formula_v2 is disabled' error (compile.go:650) through every entry point + - internal/formula, internal/graphv2, internal/graphroute, internal/sling test files contain zero SetFormulaV2Enabled/LockV2ForTest references and their flag-dependent tests run t.Parallel + - Legacy freeze golden list (cmd/gc/legacy_flag_freeze_test.go, from S1) untouched or shrink-only; GC_* env baseline test green +- **tests:** + - TDD: new-signature table test written first (both v2Enabled polarities × Compile/CompileWithoutRuntimeVarValidation/CompileExpansionFragment), red at compile, then implementation + - requirements_test.go: existing 22 flag refs replaced by direct ValidateHostRequirements(f, false/true) params — no globals + - graphroute_test.go: replaces Set/Cleanup pairs at :331/:454 with explicit param plumbing through its fixture + - make test-fast-parallel green; go vet clean +- **depends_on:** S5-T2 + +### [S5-T4] Molecule + dispatch flip: Instantiate/InstantiateFragment/ralph consume the threaded Options value; graph-apply atomic goes unread; ~21 molecule save/restore blocks deleted +Flip the second leaf. internal/molecule/molecule.go:475 (`if !opts.DeferAssignees && IsGraphApplyEnabled()`) and :784 (`if IsGraphApplyEnabled()` in the fragment path) switch to opts.formulaV2Enabled(). internal/dispatch/ralph.go:416 (`if molecule.IsGraphApplyEnabled()`) switches to the T2-threaded opts value — explicitly NOT re-resolved from loadAttemptRouteConfig(opts.CityPath) at :415, because the mode is process-latched to the boot snapshot (design §1.6/robustness: a legacy writer racing a graph-apply writer inside one process is the corruption class the latch prevents). SetGraphApplyEnabled/IsGraphApplyEnabled remain defined (still called by feature_flags.go/syncFeatureFlags) but unread by any production path. Migrate tests: internal/molecule/molecule_test.go — 42 setter references ≈ 21 save/restore blocks (e.g. :304-305, :502-503, :537-538) — plus attach_test.go (2) become `opts.FormulaV2 = ptr(false/true)` per test instance; internal/dispatch control_test.go (2), drain_test.go (4), ralph_check_asset_test.go (2), runtime_test.go (formulatest importer) migrate to ProcessOptions fields. All migrated tests gain t.Parallel — the entire reason the mutex existed disappears per-instance. Verify the graph-apply four-cell behavior survives: nil ⇒ graph-apply when store supports GraphApplyFor (default), explicit false ⇒ sequential legacy creation, transient-error retry path unchanged. + +- **files:** internal/molecule/molecule.go, internal/molecule/graph_apply.go, internal/molecule/molecule_test.go, internal/molecule/attach_test.go, internal/dispatch/ralph.go, internal/dispatch/control_test.go, internal/dispatch/drain_test.go, internal/dispatch/ralph_check_asset_test.go, internal/dispatch/runtime_test.go +- **acceptance:** + - Zero reads of IsGraphApplyEnabled() outside cmd/gc/feature_flags.go and internal/api/server.go:234 (T5 deletion targets) + - ralph.go retry routing decision comes from the threaded boot-latched value; no config re-resolution for the flag on that path (asserted by test with divergent on-disk config vs threaded value) + - internal/molecule and internal/dispatch test packages contain zero SetGraphApplyEnabled/SetFormulaV2Enabled references; molecule_test.go save/restore block count is 0; flag-dependent tests are t.Parallel + - Both transports still tested per call path: Instantiate (molecule.go:468), InstantiateFragment (:784), appendRalphRetryViaGraphApply vs appendRalphRetryLegacy (ralph.go:417/424) + - No behavior change for zero-value Options callers: nil field ⇒ graph-apply enabled (today's default), asserted explicitly +- **tests:** + - molecule_test.go: rewritten blocks — each test constructs its own Options{FormulaV2: ptr(bool)}; two t.Parallel tests with opposite values interleave safely + - dispatch drain_test/ralph_check_asset_test: graph-apply vs legacy retry chosen by ProcessOptions field, not globals + - ralph latch test: threaded value false + on-disk city.toml formula_v2=true ⇒ legacy path taken (boot snapshot wins) + - make test-fast-parallel + targeted go test ./internal/molecule/ ./internal/dispatch/ green +- **depends_on:** S5-T3 + +### [S5-T5] Root deletion: feature_flags.go + 7 call sites, syncFeatureFlags, both atomics, formulatest, freeze golden list; process-latch reload regression +With both leaves flipped, delete the entire legacy apparatus in one PR: (1) cmd/gc/feature_flags.go and its 7 call sites — cmd_start.go:673, controller.go:923, cmd_agent.go:52 and :70, cmd_sling.go:247, api_state.go:1808 (reload path), doctor_provider_catalog.go:146 — each site's needs are already met by T2 threading from loadCityConfig*-resolved Flags. (2) internal/api/server.go syncFeatureFlags definition (:229-238) and its calls in New/NewReadOnly (:198/:204); the server holds only the State-carried boot Flags. (3) The formula atomic block (compile.go ~:615-640: formulaV2Enabled, init(), SetFormulaV2Enabled, IsFormulaV2Enabled) and the molecule block (graph_apply.go:18-38: graphApplyEnabled, SetGraphApplyEnabled, IsGraphApplyEnabled). (4) internal/formulatest/v2.go (LockV2ForTest/HoldV2ForTest/SetV2ForTest/EnableV2ForTest and the v2Mu mutex) — remaining importers migrate here: cmd/gc cmd_agent_test.go (1), cmd_order_test.go (2), cmd_sling_routevars_test.go (2), cmd_formula_test.go, order_dispatch_test.go; internal/api handler_sling_test.go (4) + handler_formulas_test.go; internal/bootstrap/core_formula_report_test.go (2). cmd-level tests control the flag the honest end-to-end way: `[daemon] formula_v2 = false` in the test's city.toml fixture (exercising the real Resolve path), falling back to rollout.ForTest injection only where a DI seam exists. (5) cmd/gc/legacy_flag_freeze_test.go is deleted — its inventory reached zero; a freeze test guarding nothing is debt (design §13.5). (6) BEHAVIOR CHANGE, deliberate and release-noted: config reload (api_state.go reload path, controller.go:923) previously re-applied formula_v2 to the globals mid-run; after this PR the boot snapshot is latched and a changed daemon.formula_v2 surfaces as the S1 pending-restart Notice instead. Add the regression test for exactly that. Follow the no-semantic-search rule: before merging, grep separately for direct calls, type refs, string literals, and re-exports of every deleted symbol. + +- **files:** cmd/gc/feature_flags.go, cmd/gc/cmd_start.go, cmd/gc/controller.go, cmd/gc/cmd_agent.go, cmd/gc/cmd_sling.go, cmd/gc/api_state.go, cmd/gc/doctor_provider_catalog.go, cmd/gc/legacy_flag_freeze_test.go, internal/api/server.go, internal/formula/compile.go, internal/molecule/graph_apply.go, internal/formulatest/v2.go, cmd/gc/cmd_agent_test.go, cmd/gc/cmd_order_test.go, cmd/gc/cmd_sling_routevars_test.go, cmd/gc/cmd_formula_test.go, cmd/gc/order_dispatch_test.go, internal/api/handler_sling_test.go, internal/api/handler_formulas_test.go, internal/bootstrap/core_formula_report_test.go +- **acceptance:** + - grep -rn "applyFeatureFlags\|syncFeatureFlags\|SetFormulaV2Enabled\|SetGraphApplyEnabled\|LockV2ForTest" --include="*.go" returns zero hits, tests included (design §13.5 exit line 1) + - cmd/gc/feature_flags.go, internal/formulatest/v2.go, and cmd/gc/legacy_flag_freeze_test.go are deleted; internal/formulatest is removed entirely if empty + - Reload regression: changing daemon.formula_v2 on disk and triggering the reload path does NOT change compile/instantiate transport mid-process and emits the pending-restart notice + - No package-level mutable flag state remains for these flags: no atomic.Bool, no SetX, no test mutex; all migrated tests t.Parallel-safe + - Doctor and cmd/gc entry-point behavior with [daemon] formula_v2=false is unchanged end-to-end (fixture-driven tests through real command paths) +- **tests:** + - New cmd/gc reload regression test (boot-latched snapshot + pending-restart notice) — written first, red against the pre-deletion re-apply behavior + - handler_sling_test.go / handler_formulas_test.go: server built with ForTest Flags, both polarities, t.Parallel + - bootstrap/core_formula_report_test.go: fixture-config control replaces setter blocks + - make test-cmd-gc-process-parallel (known 600s timeout on raw go test ./cmd/gc/ — use the sharded target), make test-fast-parallel, go vet +- **depends_on:** S5-T3, S5-T4 + +### [S5-T6] Absorb the two legacy env one-offs as infra-killswitch Specs with shipped precedence preserved (EnvSemantics) +Per design §13.3 (lines 2133-2163): the two gates have OPPOSITE precedence today and absorption must not unify them. Register two Specs: (a) Key dolt.auto_gc_enabled — ConfigPath the existing [dolt] auto_gc_enabled *bool, EnvOverride GC_DOLT_AUTO_GC_ENABLED, EnvSemantics EnvFillsNil (env fills only when config is nil — explicit config wins, verified at cmd/gc/dolt_start_managed.go:973), Category InfraKillswitch (no Expires, no VersionAnchor — per-category rules permit long-lived); (b) Key events.rotation.enabled — ConfigPath [events.rotation] enabled, EnvOverride GC_EVENTS_ROTATION_ENABLED, EnvSemantics EnvOverrides (set env wins; invalid value warns and keeps config, verified at cmd/gc/providers.go:998-1002), Category InfraKillswitch. Mechanics: reroute both os.Getenv/LookupEnv reads into rollout.Resolve (the only production home for flag env reads per the 13.1.2 permanent baseline test); delete parseEnvAutoGCEnabled (dolt_start_managed.go:989+) and parseEventsRotationEnabled (providers.go:1027) in favor of the one shared bool grammar, extended with enabled/disabled (case-insensitive, trimmed) so it is a strict SUPERSET of both legacy parsers; register both vars in testenv LeakVectorVars; remove their entries from the gc_env_baseline (they moved to the sanctioned bucket). Untouched by declaration: the supervisor child-env re-export of GC_DOLT_AUTO_GC_ENABLED (beads_provider_lifecycle.go:2003/2039) is part of the shipped interface — its sites stay in the baseline. Any precedence unification is a separate release-noted breaking change, never a side effect here. The malformed-env behavior follows the S1 break-glass rule: WARN-AND-USE-CONFIG with a Notice, matching the shipped providers.go warn-and-keep semantics. + +- **files:** internal/rollout/registry.go, internal/rollout/resolve.go, internal/rollout/gc_env_baseline.go, cmd/gc/dolt_start_managed.go, cmd/gc/providers.go, internal/testenv/leakvector.go +- **acceptance:** + - os.Getenv/LookupEnv reads of GC_DOLT_AUTO_GC_ENABLED and GC_EVENTS_ROTATION_ENABLED exist only inside rollout.Resolve (design §13.5 exit line 3), with the supervisor re-export write sites intact + - Precedence is byte-identical to shipped behavior, table-tested per Spec: fills-nil (explicit config beats env) vs overrides (env beats config; contradiction emits the push-loud notice) + - Grammar superset test: every spelling either legacy parser accepted (strconv.ParseBool spellings, ON/OFF, y/yes, enabled/disabled, case/whitespace variants) parses identically under the shared grammar — no operator unit file breaks on upgrade + - Invalid GC_EVENTS_ROTATION_ENABLED still warns and keeps config (notice-carrying, not fatal, not silent) + - Both vars registered in testenv LeakVectorVars (registry env-hygiene test green); both Specs pass per-category killswitch rules (Expires/VersionAnchor forbidden) +- **tests:** + - rollout resolve_test: EnvFillsNil and EnvOverrides table tests with injected LookupEnv (no t.Setenv), covering set/unset/invalid × config nil/explicit + - Grammar superset unit test enumerating the union of legacy-accepted inputs + - cmd/gc integration: dolt auto-GC and events-rotation effective values unchanged across the matrix (entry-point tests through the real loaders) + - registry_test: both new Specs pass shape/completeness/per-category/EnvOverride-uniqueness checks +- **depends_on:** S5-T1 + +### [S5-T7] Execute the daemon.graph_workflows tombstone: RetiredKey entry, delete the alias field/branch/merge clause +Per design §13.4 (lines 2165-2181). daemon.graph_workflows is a live deprecated alias: field DaemonConfig.GraphWorkflows at internal/config/config.go:2297, honored only when formula_v2 is absent (config.go:4290-4295), with its own clause in the fragment-merge special case (compose.go:1042: `!fragMeta.IsDefined("daemon", "formula_v2") && !fragMeta.IsDefined("daemon", "graph_workflows")`). This task: (1) mint the RetiredKey entry in internal/config/undecoded.go using the mechanism S1 shipped — Key daemon.graph_workflows, RemovedIn = the gc version anchor current at this merge (never a wall-clock date), Message pointing operators at daemon.formula_v2; (2) delete the GraphWorkflows field, the alias-honoring branch at config.go:4290-4295, and the graph_workflows half of the compose.go:1042 clause — the formula_v2 IsDefined preservation branch itself STAYS (it is the flag's config home until the flag graduates to deletion under its own version anchor, design line 2131); (3) a retired key downgrades from fatal-unknown-key to a warning: a config setting graph_workflows=false now loads with the retirement warning and the value is ignored (FormulaV2Enabled() default true applies). Update the existing merge regression tests (compose_test.go TestLoadWithIncludes*FormulaV2* family) for the removed clause. + +- **files:** internal/config/undecoded.go, internal/config/config.go, internal/config/compose.go, internal/config/compose_test.go, internal/config/config_test.go +- **acceptance:** + - graph_workflows appears in the Go tree only in retiredKeys and its test (design §13.5 exit line 4) + - Loading a config with graph_workflows=false succeeds with the retirement warning; effective formula_v2 is the default (true) — alias no longer honored, not fatal + - The formula_v2 per-field IsDefined preservation branch and its hand-written merge regression test remain green (explicit formula_v2=false survives a daemon fragment defining an unrelated sibling key) + - RemovedIn is a version anchor, not a date; the nightly radar tombstone-expiry metadata is populated + - jsonschema/config-doc generation no longer emits graph_workflows (doc-gen hook output clean) +- **tests:** + - undecoded_test: retired-key warning fires with the exact message; unknown-key fatal behavior unchanged for genuinely unknown keys + - config_test: graph_workflows=false ⇒ FormulaV2Enabled()==true + warning (alias-removal semantics) + - compose_test: updated merge special-case tests; TestLoadWithIncludesPreservesExplicitFormulaV2FalseAcrossDaemonFragment still green +- **depends_on:** S5-T1 + +### [S5-T8] Exit-criteria sweep, docs and release notes, lifecycle bookkeeping, bead close +Close the milestone against design §13.5. (1) Verify all five exit criteria mechanically: zero-hit grep for the five legacy symbols including tests; the three file deletions (feature_flags.go, formulatest/v2.go, legacy_flag_freeze_test.go); env reads of the two absorbed vars only in Resolve; graph_workflows only in retiredKeys+test; and confirm the 13.1.2 GC_* env-read baseline test REMAINS as permanent infrastructure (it outlives the migration as the tax collector for any future shadow flag — do not delete it with the golden list). (2) Docs: update the contributor flag-addition checklist and AGENTS-adjacent engdocs so 'feature flag' searches land on internal/rollout, not the deleted mechanism; release-note the two operator-visible changes — (a) daemon.formula_v2 is now process-latched: live reload no longer flips the v1/v2 transport mid-run, a changed value surfaces as a pending-restart notice; (b) daemon.graph_workflows retired (warning + ignored, set daemon.formula_v2 instead). (3) Lifecycle bookkeeping: the daemon.formula_v2 Spec REMAINS registered (the config field and preservation branch are its home until its own gcFormulaV2RemovalFloor version-anchored deletion); update its Owner bead state and, if the migration completed the criteria the Spec's Expires was tracking, extend/adjust via the CODEOWNERS-gated registry.go edit. (4) Quality gates per repo rules: make test-fast-parallel, make test-cmd-gc-process-parallel, make test-integration-shards-parallel where touched, go vet ./..., pre-commit hook active, targeted package-guard tests run manually (pre-commit skips them), doctor smoke (gc doctor Rollout Flags section shows daemon.formula_v2 with origin). (5) Session-completion protocol: close the committed migration bead (bd close), push, verify up-to-date with origin. + +- **files:** internal/rollout/registry.go, engdocs/plans/feature-flags/DESIGN.md, docs release-notes location per repo convention +- **acceptance:** + - All five §13.5 exit criteria pass and are recorded in the PR/bead: symbol grep zero; files deleted; env reads confined to Resolve; graph_workflows confined to retiredKeys; env-read baseline test present and green + - The permanent GC_* env-read baseline test is demonstrably still enforced (a synthetic os.Getenv("GC_X") in a scratch branch fails it) + - Release notes document the process-latch reload change and the graph_workflows retirement + - daemon.formula_v2 Spec still registered with valid lifecycle fields; the migration bead is closed; registry.go diff (if any) went through the CODEOWNERS gate + - Full gate suite green: sharded test targets, go vet, doc-gen; git push succeeded and origin is up to date +- **tests:** + - A checked-in exit-criteria test (or CI step) encoding the §13.5 grep as a repo test so regression is impossible, kept only where it guards something permanent (the env baseline); one-shot criteria verified in-PR + - gc doctor smoke test: Rollout Flags section renders daemon.formula_v2 with value+origin on a fixture city + - make test-local-full-parallel as the pre-merge sweep +- **depends_on:** S5-T5, S5-T6, S5-T7 + +**Exit criteria:** grep -rn "applyFeatureFlags|syncFeatureFlags|SetFormulaV2Enabled|SetGraphApplyEnabled|LockV2ForTest" --include="*.go" returns zero hits repo-wide, tests included · cmd/gc/feature_flags.go, internal/formulatest/v2.go, and cmd/gc/legacy_flag_freeze_test.go are deleted; no atomic.Bool, package-level setter, or process-wide test mutex exists for any registry flag · os.Getenv/LookupEnv reads of GC_DOLT_AUTO_GC_ENABLED and GC_EVENTS_ROTATION_ENABLED exist only inside rollout.Resolve, with shipped precedence preserved per-Spec (fills-nil vs overrides) and the grammar-superset test green · daemon.graph_workflows appears only in retiredKeys and its test; the daemon.formula_v2 config field, accessor, and IsDefined preservation branch remain as the flag's home until its own version-anchored deletion · The 13.1.2 GC_* env-read frozen-baseline test remains as permanent infrastructure and is green · All formula_v2-affected test packages (formula, molecule, dispatch, graphroute, graphv2, sling, api, bootstrap, cmd/gc) control the flag via explicit params, option fields, config fixtures, or rollout.ForTest — zero save/restore blocks, t.Parallel-safe by construction · Reload is process-latched for formula_v2: regression test proves a mid-run config change cannot flip the transport and surfaces the pending-restart notice; behavior change is release-noted · off/default behavior byte-identical throughout: every intermediate PR ships with the full sharded suite (make test-fast-parallel, make test-cmd-gc-process-parallel) and go vet green; each PR independently mergeable to origin/main · The committed migration bead is closed; the daemon.formula_v2 Spec's lifecycle fields are current via a CODEOWNERS-gated registry.go review + +_General-Auto: Stage 5 is the proof that internal/rollout is a general subsystem, not a beads CAS wrapper. daemon.formula_v2 is a non-beads, *bool-kind infra-migration flag with no EnvOverride and — critically — NO capability predicate: it exercises the resolver's general path where the capability leg is vacuous (enable alone decides; Off/Require ≡ off/on), demonstrating that Auto/capability resolution is strictly opt-in per flag. T1 pins this with a test that a predicate-less Spec never touches capability machinery. By stage end the registry has four consumers (beads CAS rollout, formula_v2 migration, two absorbed infra-killswitches with divergent EnvSemantics), three of which have nothing to do with beads — and the internal/rollout zero-beads-imports boundary test passes throughout without edits. The DI shapes proven here (option-struct threading, explicit leaf parameters, config-fixture control in entry-point tests, ForTest typed options) are the reusable pattern for flag N+1, and the permanent GC_* env-read baseline plus the CODEOWNERS-gated registry are the generic anti-recruitment teeth that outlive this migration._ + +## Acceptance gates per stage + +### S1 (PR-1a..1d) +- Allowlist import-boundary test green: internal/rollout imports ONLY stdlib + internal/config (+ internal/deps); red-tested against a synthetic internal/beads AND internal/beadmeta import (names the offending package) +- rollout.ResolveCapability(mode, cap) Decision exported; full matrix (Off/Auto/Require × capable/incapable + nil-predicate + ModeUnset) table-tested INSIDE internal/rollout with a synthetic NON-beads predicate +- Spec is pure data (no func-valued fields, asserted); per-category rules enforced (Expires+VersionAnchor mandatory rollout/migration, forbidden killswitch); VersionAnchor 'pending' state (deps.env key absent) accepted for beads CAS Spec, distinct from missing/empty +- IsDefined preservation branch for [beads] conditional_writes proven red-first against whole-table LWW; hand-written merge regression trio green (explicit require survives a [beads] fragment defining a sibling key) +- Registry default-equality test: zero-value config + empty LookupEnv resolves each Spec to its Default (both day-one Specs) +- Env break-glass: malformed value → WARN-AND-USE-CONFIG + env_overrides_config Notice via injected LookupEnv (no t.Setenv); precedence Origin-tracked +- PR-1c: applyFeatureFlags/syncFeatureFlags call sites BYTE-UNTOUCHED (freeze inventory pins them); Resolve runs beside them; boot-latched snapshot + pending-restart notice on reload; State.RolloutFlags() served from boot, proven by stale-on-disk-config entry-point test +- Doctor Rollout Flags section renders value+origin per Spec; EffectiveStatus + worst-of aggregator live in internal/rollout; exit contract fail_closed→nonzero, degraded→0 +- PR-1d: repo-root-walk freeze inventory (cmd/gc 7 sites + def, internal/api/server.go 3, formula/molecule setter defs) + per-package test-reference ceilings; GC_* env baseline (non-test files only, stated in the test doc) red-tested with a scratch os.Getenv("GC_X") +- TestConditionalWritesGraduation dormant (key absent → pass) and armed (synthetic dotenv) both proven; expiry check fires ONLY when registry.go is in the diff — no time.Now() in merge-blocking CI +- undecoded.go RetiredKey mechanism: warning-not-fatal, RemovedIn is a version anchor; unknown-key fatal behavior unchanged +- Entry-point tests scoped to the seam: temp city.toml require → Mode=Require+Origin=config at each composition root; bd-wire probe-write leg deferred BY NAME to S3 four-cell tests +- make test / test-fast-parallel, go vet clean per PR; each of the four PRs green and mergeable alone + +### S2 (PR-S2a + PR-S2b) +- NO json-visible Revision on beads.Bead: internal/api/openapi.json byte-untouched by PR-S2a; TestOpenAPISpecInSync green; revision held store-internally (bdIssue decode envelope / store records) and observed via unexported accessor + PreconditionFailedError.Current +- RunConditionalWriterConformance green over Mem/File/Caching-over-Mem in unit CI, with openIncapable factories per store (no store-specific branches in subtest bodies) and the reflection-driven mutation-exhaustiveness subtest (every Store method + satisfied write interface classified bumps/no-bump/exempt-with-reason; unclassified fails) +- Classifier table fully covered incl. exit-9-with-body, noise-wrapped, exit-13-code-gated latch, bare-13 no-latch, unknown-flag latch, ambiguous-committed; latch authoritative over probe both directions; zero conditional-write path through runBDTransientWrite (grep + argv test) +- MERGE GATE: CachingStore livelock regression green (evict on CAS-success-failed-refresh AND every PreconditionFailed; retry converges) using the new scripted-error beadstest double +- Emulation loop bounded (4 attempts, jittered), typed exhaustion distinct from PreconditionFailed and (false,nil); §8.4 spike verdict recorded with the revision-bump disqualifier answered +- PR-S2b: mode has ONE home (factory stamp via ModeStamped optional interface); ResolveConditionalWriter takes no mode param; FULL matrix incl. Require∧capable and Unset→Off+recorded-diagnostic; grep/AST guard: no rollout.ModeAuto/ModeRequire comparison in internal/beads outside the stamp; bypass inventory (hook-claim, scoped stores, bd_env, MemStore/FileStore sites) classified with unstamped→Off pinned by test +- beads.conditional_writes.degraded REGISTERED only (full field set: store_id, store_kind, mode, origin, reason, bd_version) with its spec-regen commit (make spec-ci, six artifacts) — emission call count in tree is ZERO; nil-safe OpenOptions.OnConditionalWritesDegraded callback SHAPE defined here, wired in S3 +- Integration row pins the bd binary via the contract-test system mechanism (no new GC_* var); untagged-bd degrade contract asserted (incapable probe, typed unsupported, NO unconditional argv); skips ledgered +- Sweep: make test-fast-parallel, go vet, make spec-ci, go test ./internal/api/ -run 'OpenAPISpecInSync|EventPayload', allowlist boundary test, freeze/baseline tests; zero production ConditionalWriter references outside internal/beads; NO sqlite deliverable in S2 (dropped from EXIT) + +### S3 (S3.0b + PR-S3-main + PR-S3-deploy) +- S3.0b: deploy lineage synced with main (S1+S2 suites green on the synced branch) BEFORE any deploy-side task; sync commit named in the S3.8 checklist +- C6: three-outcome self-win contract (self-win / one bounded re-issue / other-owner skip), symmetric CAS release with loss-is-debug-noop, per-member owning-store capability, FULL matrix (incl. Require∧capable and mid-run latch-trip under Require), off-mode golden call-trace byte-identical +- C4: CAS-last pinned; findExistingAttach molecule_failed guard changed to continue-past (both enumeration orders seeded); ALL THREE epoch writers converted (trailing increment, syncControlEpochToAttempt, advanceAttachEpochIfNeeded); loser neutralization + crash-between-markFailed-and-DepRemove recovery test; CASRetriesExhaustedError → transient, NO loser-walk, no molecule_failed stamp (scripted churn row) +- BLOCKING: sqlite_store_conditional.go (SOLE author = S3.3) + all five integration legs green ON the synced deploy lineage, run by the NAMED CI venue (deploy-branch workflow, integration shard target); ResolvedPathCapability leg asserts the resolved graph store reports the CONFIGURED mode (auto/require), not just interface satisfaction +- Wrapper transparency across the FULL resolved path: noCloseGraphStore, lazyGraphStore (open-error-not-unsupported), beadPolicyStore both directions, coordrouter.Router per-ID routing with heterogeneous-verdict test, CachingStore (assert), PLUS ModeStamped forwarding through the real composition factory→caching→policy(→router) +- Deploy graph store stamped at its real open sites (registerGraphStoreBackend, lazyGraphStore heal, graphStoreHandleCache hit path) — an unstamped-Off fence is unconstructable on the deployed topology +- Observability: emission wired via the S2-defined callback at BOTH roots, once-per-store hammered; doctor graph-class row always rendered with probe/latch split; multi-writer predicate DEFINED (>1 resolved writer store per CAS-target ledger; host CLI writers documented as always-present); exit contract incl. the require+multi-writer+non-CAS-writer → ERROR nonzero cell (DESIGN:2541) +- Runbook shipped verbatim (require forbidden pre-soak, mixed-writer invariant citing the S2 bypass inventory, ABA rule, lift conditions); doctor ERROR and runbook name each other +- S3.8: both PRs cross-referenced; cherry-pick-clean verified against a main-shaped throwaway; repo gates green both lineages; pushed; soak bead opened with dated evidence fields + +### S4 (PR-A, PR-B, flip PR) +- S4-T0 hard gate: tagged release verified against real artifacts (four verbs advertise --if-revision; exit-9/13 bodies observed; Issue.Revision in the module; two-knob tag mapping recorded) PLUS S4-T0b: beads schema-version delta, old-bd-vs-migrated-DB behavior, migrate-on-read-vs-write — and a WRITTEN rollback procedure in the runbook (pin revert safe/unsafe boundary, roll-forward or BD_IGNORE_SCHEMA_SKEW playbook) +- PR-A: TestBDVersionPins green (BD_VERSION=, four SHA tuples, all workflow pins); anchor lockstep assertions (const==deps.env, floor>bdMin, BD_VERSION>=floor) TDD'd; Spec VersionAnchor pending→concrete; probe = version-compare through the ONE runner seam with rc/dev/suffixed/gibberish rows pinned and runbook note for source-built bd; graduation satisfied via FlipDueBy= (CODEOWNERS-reviewed), Default still Off; zero behavior change at off +- PR-B: Revision lands ONCE (field + json tag + omitempty); conformance revision-visibility extension green over Mem/File/Bd(fake)/Caching + native-Dolt round-trip; S2 oracle retired; two isolated regen commits; six artifacts drift-free (make spec-ci); events fixture test: pre-revision AND with-revision events.jsonl both decode through the runs-view projection +- C2: 4-verbs × 4-rows (16 cells) + parseStrongETag reject table + 412 round-trip + legacy golden; If-Match on ANY non-CAS-wired bead mutation (reopen included) → LOUD 501/422, never silently dropped — guard test enumerates every registered bead mutation op; ETag iff Revision>0, never stale after 412 +- Status wire: Effective computed by rollout.AggregateEffective (no second truth table in internal/api); env-contradiction boundary test (notice on wire); doctor live-API vs stopped-city fallback; dashboard TS regen restored + red-tested drift gate; make dashboard-check + dashboard-ci green; ga-iialk6 closeable +- PR-B independently mergeable: full suite green with OLD bd pinned (probe=incapable); allowlist boundary test green on final tree; C2 revert contract pinned (client sends If-Match only after an ETag-bearing GET; revert procedure stated) +- Flip PR: mechanically dep'd on S3.8 soak bead CLOSED with evidence; registry data-only diff (Default=Auto, GraduatedIn, FlipDueBy cleared) + zero-value accessor test moved in lockstep; emergency un-flip = [beads] conditional_writes="off" in city.toml + restart (config beats default), documented in AC + runbook + +### S5 (formula_v2 migration) +- S5-T1: Flags.FormulaV2() config-only (no env leg); WithFormulaV2 typed option; predicate-less Specs NEVER invoke capability machinery (reworded: capability enters only via the explicit per-call resolve — Specs carry no predicates); boundary test untouched +- S5-T2 (blocked on PR-S3-main merged): *bool nil⇒true on molecule.Options/FragmentOptions/ProcessOptions; production construction-site inventory in PR body EXPLICITLY including cmd/gc/cmd_sling.go's Options feeder; cmd-level fixture test through gc sling mirroring gc formula; leaves still on globals — runtime byte-identical +- S5-T3: explicit v2Enabled param on all three compile entry points, 16 production callers converted, listed test packages migrated + t.Parallel; only feature_flags.go/server.go:231/formulatest still read IsFormulaV2Enabled (grep-verified) +- S5-T4: zero IsGraphApplyEnabled reads outside deletion targets; ralph boot-latch test (threaded false beats on-disk true); ~21 save/restore blocks = 0 +- S5-T5: grep-zero for the five legacy symbols repo-wide incl. tests; feature_flags.go + formulatest/v2.go + legacy_flag_freeze_test.go deleted; reload regression red-first against pre-deletion re-apply, then green with pending-restart notice; release-noted; formulatest-importer sweep of open branches done pre-merge +- S5-T6: EnvFillsNil vs EnvOverrides preserved per-Spec (table tests); grammar strict-superset test over both legacy parsers' accepted spellings; both vars in LeakVectorVars; env reads only in Resolve (supervisor re-export sites intact, stay in baseline) +- S5-T7: graph_workflows only in retiredKeys+test; VALUE-AWARE warning — graph_workflows=false (behavior-changing) surfaces at doctor-ERROR/pending-restart grade, not just a load line; formula_v2 IsDefined branch + regression test remain green +- S5-T8: all five §13.5 exit criteria recorded; GC_* baseline test proven still enforced; sharded gates (test-fast-parallel, test-cmd-gc-process-parallel — never raw go test ./cmd/gc/) + vet green; migration bead closed; pushed + +## Added tasks (from hardening) + +- **S1** — Full Stage-1 task breakdown (fourteen tasks across PRs 1a/1b/1c/1d): See stage1_task_order for the exact TDD sequence. Key shape decisions pinned to prevent guess-wrong builds: config holds a plain validated string (Mode mapping in rollout — import-cycle fix, DESIGN §4.1 errata); Resolve wired BESIDE the untouched legacy calls; expiry = registry.go-diff-gated, never wall-clock in merge CI; VersionAnchor 'pending' state; State.RolloutFlags() as the API vehicle; radar deferred with owner bead; entry-point tests scoped to the seam with the bd-wire leg deferred by name to S3. _(why: BLOCKER in all six lenses: the plan consumed ~40 S1 artifacts built by no task; internal/rollout, RetiredKey, the graduation test, the freeze/baseline teeth, and doctor_rollout.go do not exist in the tree (verified).)_ +- **S1** — Synthetic non-beads Auto capability test (internal/rollout/capability_test.go): Drives rollout.ResolveCapability with a fake runtime-provider-style predicate through all mode×capability cells + nil-predicate + ModeUnset, using only rollout types; referenced by name from the S2/S3 GENERAL_AUTO exit criteria. _(why: The user's acceptance condition had zero non-beads Auto coverage — the general resolver's de-facto spec would have been the beads adapter.)_ +- **S2** — Conformance harness: openIncapable factory option + reflection-driven mutation exhaustiveness: Options struct with optional openIncapable func(t) beads.Store (Mem/File pass Disable-toggled constructors, BdStore a scripted-incapable runner, sqlite nil + ledgered skip); a reflection subtest enumerates every beads.Store method plus every satisfied write-bearing optional interface (ReleaseIfCurrent, claim, GraphApplyFor, Tx, DepAdd/DepRemove, CloseAll) and fails on any method absent from a checked-in bumps/no-bump/exempt classification table; dep-edge and GraphApply bump semantics decided in the interface doc comment. _(why: The disable_toggle row was inexpressible under the single-factory signature, and a missed write method would train consumer retry loops on stale revisions with no test to catch it.)_ +- **S2** — beadstest scripted-error store double (per-call errors keyed by method+call-count): Extends the recording store (or adds a failing-store double) so the CachingStore livelock MERGE GATE (fail-once refresh Get, injected PreconditionFailed) is implementable as specified. _(why: MemStore has no fault-injection seam; the merge-gate test was unimplementable as written.)_ +- **S2** — Mode-stamp mechanism spec + factory-bypass inventory + unstamped→Off pin: Optional interface{ ConditionalWritesMode() rollout.Mode } implemented by every concrete store from a StoreOpenOptions field, forwarded by CachingStore and every S3.4 wrapper (stamp forwarding added to those tasks' ACs); real-composition test (factory→NewCachingStore→policy wrap) asserts the stamp survives to the outermost value; grep-inventory of every production beads.New*/Open* call outside the factory (cmd_hook_claim.go:579, scoped_store.go:27/36, bd_env.go×4, cmd_bd_store_bridge.go:142, main.go:1136, cmd_start.go:890) classified CAS-writer/read-only/test-only; unstamped store resolves Off (never panics, never Auto) pinned by unit test; matrix extended with Require∧capable and Unset→Off+diagnostic cells; the inventory is the citation for S3.7's mixed-writer runbook section. _(why: The stamp's read-back through wrappers was unspecified, the 'stamps EVERY store' claim was false on main (caching/policy wrap outside the factory), and the CLI-writer set feeding the mixed-writer invariant was un-inventoried.)_ +- **S3** — Deploy-lineage sync predecessor (BLOCKS S3.3/S3.4a/S3.5): Merge origin/main (carrying merged S1+S2) into deploy/sqlite-b36-probe-attribution — or cut a fresh deploy branch off current main and re-apply the sqlite commit stack — then run the S1/S2 suites green on the synced lineage; re-anchor every 'applies clean' AC and the S3.8 cherry-pick claims to the synced lineage; name the sync commit in the S3.8 checklist. _(why: The deploy branch is 220 commits behind with zero ConditionalWriter surface; S3.3 could not compile there and cross-220-commit cherry-picks of drain.go/molecule.go would conflict heavily.)_ +- **S3** — C4 completeness: advanceAttachEpochIfNeeded conversion + findExistingAttach guard change + crash-window and exhaustion tests: (a) advanceAttachEpochIfNeeded (molecule.go:380-394, the third gc.control_epoch writer on the idempotent-reentry path) converts to CompareAndSetMetadataKey with benign-loss semantics and joins the off-mode golden trace + a duplicate-path matrix row; (b) the molecule.go:343 guard changes from error-on-molecule_failed to continue-past (error only when no live candidate), with TestAttachEpochFence_ConcurrentAttach seeding BOTH List enumeration orders; (c) TestAttachEpochFence_CrashBetweenMarkFailedAndDepRemove: fault-injected DepRemove failure after markFailed, assert the level-triggered pass recovers via failedAttemptAttachRootID/partialAttemptAttachError and the attach bead does not wedge; (d) CASRetriesExhaustedError (and every non-PreconditionFailed error) at the fence does NOT run loser-neutralization — surfaces transient, re-enters via findExistingAttach; scripted churn row asserts no molecule_failed stamp and exactly one live sub-DAG. _(why: A third unconditional epoch writer voided the fence; the plan's own merge-gate test was unpassable (or flaky) against the actual :343 error-on-failed-root behavior; the crash window and exhaustion class were acknowledged in RISKS but untested/unspecified.)_ +- **S3** — coordrouter.Router ConditionalWriter forwarding + deploy graph-store mode-stamping: Router gains per-ID conditional-verb routing to the owning backend (modeled on its GraphApply shim), capability iff the routed backend has it, heterogeneous-verdict test row (capable graph + incapable work backend); the deploy lineage's OpenSQLiteStore call sites (registerGraphStoreBackend, lazyGraphStore re-open/heal, graphStoreHandleCache hit path) stamp the resolved mode so cached handles carry it; S3.5 leg 1 asserts the resolved graph store reports the CONFIGURED mode, not merely interface satisfaction. _(why: The controller holds policy(Router(work+graph)) on deploy and the graph store is opened via OpenSQLiteStore, not the factory — without these, the fence silently resolves Off on the exact topology the BLOCKING deliverable exists for, while every main-side test passes.)_ +- **S3** — Named CI venue for the blocking deploy-lineage integration gate: A workflow on the deploy branch (or a nightly/rc-gate job checked out on it) runs graph_store_sqlite_cas_test.go via the documented integration shard target; S3.8 checklist item [4] references the workflow name + branch as a repeatable CI artifact. _(why: A //go:build integration test on a branch with no CI pipeline degrades the merge gate to a one-shot local run whose evidence rots.)_ +- **S3** — Multi-writer topology predicate + the require+multi-writer ERROR cell: Pin the predicate: more than one resolved writer store for a CAS-target ledger (work store ≠ graph store, or any per-rig member store); host-local CLI writers documented as always-present and covered by the runbook rule + S2-T10b inventory; TestDoctorRolloutExitContract gains the require + declared-multi-writer + any-non-CAS-writer → ERROR nonzero cell (locked decision DESIGN:2541), cross-referenced from the runbook. _(why: The design's blocking doctor ERROR was missing from the plan and 'declared multi-writer' had no detection definition anywhere — doctor would read ACTIVE exactly when mutual exclusion is void.)_ +- **S4** — bd pin rollback/schema-migration assessment (gates PR-A): Extend T0's verification: beads schema-version delta v1.1.0→; empirical old-bd-vs--migrated-DB behavior; migrate-on-read vs on-write; written rollback procedure in the runbook ('pin revert safe until first write, unsafe after — roll forward or hand-migrate with BD_IGNORE_SCHEMA_SKEW per the maintainer-city playbook') linked from the PR-A description checklist. _(why: Two recorded fleet incidents (dispatcher death via schema skew; v53/v54 claim breakage) prove 'pin motion only' is false for this repo — reverting deps.env after a migration is an outage, not a rollback.)_ +- **S4** — Deploy-side port of the revision conformance extension + sqlite revision read: Removed from PR-B's files list (a main PR cannot carry deploy-only edits); delivered via the recurring lineage sync established by S3.0b, with the sqlite conformance revision row asserted green post-sync. _(why: S4-T5 listed a file that does not exist and cannot compile on origin/main; S4 had no dual-lineage choreography.)_ +- **S4** — Events-wire cross-version decode fixture test: One round-trip case in the event payload contract suite: a checked-in pre-revision events.jsonl fixture AND a with-revision fixture both decode through the runs-view projection, pinning omitempty tolerance as a contract. _(why: Every Bead-embedding event payload silently gains revision on the events wire; tolerance was assumed, not tested, in either direction.)_ +- **S4** — If-Match loud-rejection guard on non-CAS-wired bead mutations: Declare If-Match on BeadReopenInput (and any bead-mutating op outside the four verbs) returning 501 conditional_writes_unsupported (or 422) when present; a guard test enumerates every registered bead mutation operation and asserts each either implements the full four-row semantics or rejects a presented If-Match. _(why: Huma silently drops undeclared headers — reopen/create with If-Match would execute unconditionally while the client believes the precondition held: the exact silent-unconditional-write class the design forbids.)_ +- **S4** — Mechanical soak dependency + emergency un-flip path for the default flip: S4-T12 deps become 'S4-T11 AND S3.8-soak-bead-closed' (the bead S3.8 opens, closed with recorded evidence); AC + runbook state the emergency path: fleet config override ([beads] conditional_writes="off" + controller restart — precedence beats registry default), and any registry-level un-flip must re-set FlipDueBy= in the same diff to stay CI-green. _(why: Nothing in the dependency graph prevented flipping the day PR-B merged, and a naive registry revert reds the graduation test mid-incident.)_ +- **S5** — Explicit cross-stage ordering + cmd_sling inventory + formulatest sweep: S5-T2 blocked by PR-S3-main merged (shared drain.go/molecule.go regions); S5-T5 gated on a checklist sweep that no open feature branch imports internal/formulatest (13 importers today); cmd/gc/cmd_sling.go's molecule.Options construction site named explicitly in S5-T2's files/inventory with a cmd-level fixture test (formula_v2=false through gc sling) mirroring the gc formula one. _(why: S5 rooted only at S1, permitting a parallel run that would collide with S3's rewrites of the same functions; the sling feeder was covered only by prose and its miss would leave the sling CLI silently on registry-default.)_ + +## Amendments (from hardening) + +- **S2**: S2-T1 rewritten: NO json-visible Revision on beads.Bead in Stage 2. bd's revision is decoded on the bdstore-private bdIssue envelope (parseIssuesTolerant path) and held as store-internal state (Mem/File records; FileStore persists it in its own on-disk envelope); the conformance suite observes it through an unexported accessor and PreconditionFailedError.Current — exactly the oracle S4-T5 already assumed. The 'internal/api/openapi.json untouched' AC is now satisfiable. The exported field + json tag + spec regen land exactly once in S4-T5/T7; S4-T5's 'add Revision again' language and 'retire the oracle' framing now describe genuinely new work (real-bd decode validation, native-Dolt mapping, sqlite column read, stale-ETag-after-412 leg). _(BLOCKER: beads.Bead is a Huma wire type today (handler_beads.go:374-375, verified) — the S2 field reds TestOpenAPISpecInSync, forces an unplanned wire change while the dashboard TS generator is still unrestorable, and contradicted S4-T5's own text.)_ +- **S2**: S2-T9 DELETED; sqlite ConditionalWriter authorship moves wholly to S3.3 (which S3.4/S3.5 already depend on, on the lineage with the choreography task). S2's EXIT drops every sqlite clause ('SQLiteStore where it compiles'). Early de-risk of the deploy cherry-pick becomes a spike note in S2, not a build task. Every named sqlite test (EpochFenceExclusion, RevisionMigrationIdempotent, IndexAndBeadJSONAgree) now appears in exactly one task's test list (S3.3/S3.5). _(BLOCKER: identical deliverable specified twice in full; a file referencing *SQLiteStore cannot compile on origin/main, making S2 a hidden dual-lineage stage with no choreography.)_ +- **S2**: S2-T11 is REGISTRATION-ONLY: event constant + typed payload with the FULL S3.6 field set (store_id, store_kind, mode, origin, reason, bd_version — regen'd once) + the spec-regen commit (make spec-ci, all six tracked artifacts) added to its files/AC; the nil-safe OpenOptions.OnConditionalWritesDegraded callback SHAPE (latch beside the capability latch, matching the caching_store_events.go precedent) is defined here but emission ACs/tests are deleted — the resolve seam returns a Diag only. S2's EXIT line reworded to 'registered; emission lands in S3'. Package-guard note corrected to include internal/api's event_payloads_coverage_test.go. S2-T12 deps corrected to T6,T7,T8,T10,T11; the stage is declared as two PRs (S2a=T1–T8 pre-S1-mergeable, S2b=T10–T12 blocked on PR-1c); binary pinning for the integration row routes through the contract-test system mechanism (internal/beads/contract, PR #3714) — no new GC_* var, and the S1 baseline explicitly covers non-test files only. _(HIGH×3: emission was built twice with contradictory ACs across the stage boundary (S3.0 required zero emission in-tree); registering a payload drifts the generated spec with no S2 regen gate; the dep list let the exit sweep run before the artifacts it asserts existed; GC_TEST_BD self-contradicted the task's own no-new-env AC.)_ +- **S2**: S2-T10 gains the general-auto teeth: the task text names rollout.ResolveCapability(mode, cap) Decision as the computation home; the cell-product table test moves into internal/rollout; new AC = grep/AST guard that no rollout.ModeAuto/ModeRequire comparison exists in internal/beads outside the factory stamp, with ResolveConditionalWriter only mapping the Decision onto (writer | Diag | error). Matrix expanded to Require∧capable + ModeUnset→Off-with-recorded-BeadsDiagnostic. Boundary test references switch to the allowlist form everywhere it is re-asserted (S2-T12, S4-T11, S5-T8). _(BLOCKER/HIGH (general-auto): an implementer could put all mode branching inside the adapter and pass every stated AC including the import test — beads-locking the subsystem by construction; Require∧capable (the post-graduation operating cell) and the unthreaded-mode visibility tooth were untested.)_ +- **S3**: S3.1/S3.2 matrices upgraded from four cells to the full template (Require∧capable→CAS leg; Require + mid-run latch-trip leg). S3.2's scope explicitly includes the three items in added-task S3.2b (third epoch writer, :343 guard change with both enumeration orders, crash-window + exhaustion rows) — 'zero new recovery machinery' now means 'one guard-semantics change + existing machinery, exercised by test'. S3.5 leg 1 asserts configured-mode reporting (not just interface satisfaction) so an unstamped-Off store cannot pass. S3.8's cherry-pick and 'applies clean' ACs re-anchor to the S3.0b-synced lineage and name the sync commit; checklist item [4] references the S3.5b CI workflow by name. _(The stage-3 merge gate was unpassable (or latently flaky) as written against actual findExistingAttach behavior; an interface-only resolved-path assertion could not catch the unstamped-Off failure; the blocking gate had no repeatable venue; the RISKS-acknowledged crash window had no test.)_ +- **S3**: S3.6 consumes rollout.AggregateEffective (built in S1-T9) instead of hand-rolling the worst-of table; emission work reduces to wiring the S2-defined callback at both composition roots + the two entry-point wiring tests + the once-per-store concurrent hammer; gains the multi-writer predicate definition and the require+multi-writer+non-CAS-writer→ERROR cell (S3.6b). S3.7's mixed-writer section cites the S2-T10b bypass inventory instead of the hand-wave. S3's GENERAL_AUTO 'doctor rows for free' sentence struck — claims now match deliverables. _(The effective-status vocabulary was defined twice on consumer surfaces guaranteeing doctor/status drift; the locked DESIGN:2541 ERROR cell was missing; the runbook's writer-set claim had no backing inventory.)_ +- **S4**: PR-A: T0 gains T0b (schema/rollback assessment, written revert procedure gating merge). T2 flips the Spec's VersionAnchor pending→concrete as an explicit AC (the S1 'pending' state resolves here). T3 gains rc/dev/suffixed/gibberish version-string test rows pinning deps.CompareVersions verdicts + a runbook note for source-built bd fleets (probe=incapable is legible in doctor; fix = rebuild with a proper version stamp + restart). T4's radar AC rewritten against the deferred-radar reality: doctor WARN on FlipDueBy-pending + a manually filed pending-flip bead linked in the PR body (option (b), YAGNI-correct at N=2). _(The bump had no rollback story despite two recorded schema-skew incidents; source-built bd (the fleet's documented recovery path) would silently flip an active CAS fleet to legacy after T3; scripts/rolloutradar was consumed but never built.)_ +- **S4**: PR-B: T5's files list drops the deploy-only sqlite file (→ S4-T5b via lineage sync); T7 gains the events cross-version fixture test (T7b); T8 gains the If-Match loud-rejection guard + enumeration test (T8b) and the apierr-vs-openapi_problem_types check stays an implementation-time branch; T9's Effective is computed by rollout.AggregateEffective (AC: no second truth table in internal/api); T11 adds the C2 revert contract (client sends If-Match only after an ETag-bearing GET; ETag absence = CAS-unsupported fall-back; PR-B revert procedure incl. regen order) to the runbook + PR body, and re-asserts the allowlist boundary test. T12 gains the mechanical soak dependency + emergency un-flip path (config override, never a bare registry revert). _(A main PR carried an uncompilable deploy edit; presented-precondition silent drops and the server-downgrade window reintroduced the lost-update class C2 exists to kill; the flip was schedulable the day PR-B merged.)_ +- **S5**: S5-T1's test reworded: 'Resolve and typed accessors never invoke capability machinery for ANY flag; capability enters only through the explicit per-call resolve' — Specs carry no predicates (pure-data assertion lives in S1-T2). S5-T2 gains cmd_sling.go's Options site + the gc-sling fixture test and is dep-blocked on PR-S3-main. S5-T5 gains the formulatest-importer sweep gate. S5-T7's RetiredKey warning is value-aware: graph_workflows=true → plain retirement warning; graph_workflows=false (behavior-changing: today it resolves FormulaV2=false via config.go:4290-4295, post-tombstone it would silently flip to v2) → doctor-ERROR/pending-restart-grade surface, with the release note as a second surface, not the only one. S5-T8 re-runs the allowlist boundary form. _(The Spec-held-predicate wording contradicted S2-T10 and would force either a boundary violation or global predicate state; the tombstone silently flipped opt-out operators' transport; the sling feeder and branch-coordination constraints were prose, not dependencies.)_ + +## Gap-analysis findings (folded into the plan above; kept for traceability) + +### completeness — SERIOUS_GAPS +- [BLOCKER] (S1) Stage 1 is entirely absent from the execution plan. The brief names five stages; the plan starts at S2. Dozens of downstream ACs consume named S1 artifacts that do not exist in the tree today (verified): internal/rollout (no such package), the import-boundary test, gc_env_baseline.go, cmd/gc/legacy_flag_freeze_test.go, the doctor Rollout Flags section, rollout.ForTest, TestConditionalWritesGraduation, scripts/rolloutradar, CODEOWNERS on registry.go, the [beads] conditional_writes config field + IsDefined preservation branch (internal/config/compose.go:1030 shows [beads] is whole-table LWW today), and the undecoded.go RetiredKey mechanism S5-T7 says 'S1 shipped' (internal/config/undecoded.go has no RetiredKey/retiredKeys symbol). S2-T10, S3.6, S4-T2/T4/T9, and S5-T1/T5/T6/T7 all dangle on these. → **fix:** Add the full S1 task breakdown to the plan (or attach the separate S1 plan document and cross-reference its task IDs) before treating S2 as startable. Every S1 artifact consumed downstream must map to a concrete S1 task: internal/rollout package + resolver + Origin + env break-glass, the boundary test, both composition-root Resolve wirings, [beads] conditional_writes + IsDefined branch + merge regression test, doctor section, graduation/expiry CI teeth, gc_env_baseline.go, legacy freeze tests, the RetiredKey mechanism in internal/config/undecoded.go, CODEOWNERS, and the internal/prompt PR-1a extraction. S5-T1-style 'verify/complete' checkpoints cannot substitute for the stage. +- [BLOCKER] (S2-T1 / S4-T5 / S4-T7) S2-T1's claim that Bead.Revision is 'INTERNAL only' is false: beads.Bead is a Huma wire type TODAY — internal/api/openapi.json:838 has components.schemas.Bead, and BeadGraphResponse embeds beads.Bead at internal/api/handler_beads.go:374-375. Adding `Revision int64 json:"revision,omitempty"` in S2 changes the generated OpenAPI, reds TestOpenAPISpecInSync, and violates S2-T1's own AC ('internal/api/openapi.json untouched'). The plan then adds the SAME field a second time in S4-T5, and S4-T7 attributes the schema drift to T5 — a staging contradiction with duplicated ownership. → **fix:** Pick one owner. Option A (matches the design's C2-in-S4 staging): in S2-T1 keep Revision off the JSON surface — unexported field or json:"-" with BdStore decoding revision via a private decode envelope — and let S4-T5 own adding the json tag plus the field-only spec regen. Option B: accept the wire change in S2, move S4-T7's 'field-only regen commit' (all six tracked artifacts + genclient) into S2-T1's files/AC, and delete the field-add from S4-T5 leaving only per-store bump-discipline work. Either way, rewrite S2-T1's AC and the S2 risk note to match. +- [HIGH] (S2-T11 / S2-T12) Registering the beads.conditional_writes.degraded payload changes the generated spec artifacts, and S2 has no regen task or gate for it. Registered event payloads appear in internal/api/openapi.json (BeadClaimRejectedPayload occurs 10 times there; genspec's events.json $refs the OpenAPI envelope schemas), so events.RegisterPayload in S2-T11 forces `make spec-ci` (openapi.json, docs/reference/schema copies, events.{json,txt}, genclient/client_gen.go). S2-T11's files list omits all six artifacts and S2-T12's exit sweep omits make spec-ci. Additionally, T11's manual-guard note ('run go test ./internal/events/') misses that the canonical registration test lives in internal/api/event_payloads_coverage_test.go. → **fix:** Add the spec-regen commit (make spec-ci + the six tracked artifacts) to S2-T11's files and AC, add `make spec-ci` and `go test ./internal/api/ -run 'OpenAPISpecInSync|EventPayload'` to the S2-T12 gate list, and correct the package-guard note to include internal/api's coverage test. +- [HIGH] (S2-T10 / S3.4 / S3.5) The deploy-lineage graph store — the exact store where C4/C6 execute (.gc/beads.sqlite holding gc.control_epoch / gc.exclusive_drain_reservation) — never receives the factory mode-stamp. Verified on origin/deploy/sqlite-b36-probe-attribution: the graph store is opened via beads.OpenSQLiteStore(...) inside cmd/gc (lazyGraphStore heal path and registerGraphStoreBackend/graphStoreHandleCache in api_state.go), NOT via beads.OpenStoreAtForCity; the deploy factory.go has zero sqlite references. With S2-T10 declaring OpenStoreAtForCity 'the mode's ONE home', ResolveConditionalWriter on the resolved graph store reads a zero-value (Off) mode and the fence silently stays legacy on the deployed topology while every main-side test passes — the precise failure the BLOCKING sqlite deliverable exists to prevent. → **fix:** Add a deploy-lineage task stamping the resolved mode at the OpenSQLiteStore call sites (registerGraphStoreBackend, lazyGraphStore re-open, and the graphStoreHandleCache hit path so cached handles carry it too). Extend S3.5 leg (1) ResolvedPathCapability to assert the resolved graph store reports the CONFIGURED mode (auto/require), not merely ConditionalWriter interface satisfaction — an interface-only assertion cannot catch an unstamped Off store. +- [HIGH] (S3.4) coordrouter.Router is missing from the wrapper-transparency list. On the deploy lineage the controller composition is policy(Router(work+graph)) via routedPolicyStore (verified: internal/coordrouter/router.go exists there, with explicit per-interface GraphApply shims routed through Backend(class)/backendForID). S3.4 covers noCloseGraphStore, lazyGraphStore, and beadPolicyStore only. Consumers hold the routed policy store, so CompareAndSetMetadataKey(memberID/attachBeadID, ...) must route per-ID through the Router to the sqlite graph backend; without a forwarding shim the type assert on the resolved store fails (auto degrades to legacy) or, worse, a naive forward hits the wrong backend. → **fix:** Add coordrouter.Router ConditionalWriter forwarding to S3.4 (deploy-lineage), modeled on its existing GraphApply shim: route each conditional verb by bead ID/class to the owning backend, propagate capability iff the routed backend has it, and add a Router row to the wrapper pass-through test (capable graph backend + incapable work backend → per-ID heterogeneous verdicts). +- [HIGH] (S3.2) A third gc.control_epoch writer is never converted: advanceAttachEpochIfNeeded (internal/molecule/molecule.go:380-394), called from findExistingAttach's duplicate/idempotent-reentry path (:363), does a read-then-store.SetMetadata epoch advance. S3.2 converts only the trailing increment (:308-310) and control.go's syncControlEpochToAttempt (:313). Under CAS mode this leaves an unconditional epoch writer racing the fence on the same key from inside the same process — exactly the mixed-writer violation the design's invariant forbids, and it reintroduces the lost-update window on the re-entry path. → **fix:** Add advanceAttachEpochIfNeeded to S3.2's conversion scope: CompareAndSetMetadataKey(attachBeadID, key, itoa(expectedEpoch), itoa(expectedEpoch+1)) with syncControlEpochToAttempt-style benign-loss semantics (re-read; current > expected → nil). Include it in the off-mode golden call-trace test and add a duplicate-path four-cell test row. +- [HIGH] (S2-T10) The mode-stamp read-back mechanism through the real wrapper composition is unspecified, and the 'stamps EVERY store it opens (bd fallback, native, wrappers)' claim is false on main too: the controller's resolved store is policy(caching(factory-base)) — beads.NewCachingStore wraps the factory result at cmd/gc/api_state.go:203 and wrapStoreWithBeadPolicies wraps again, both OUTSIDE the factory. ResolveConditionalWriter(store) receives the outermost wrapper; nothing in the plan says how the stamp placed on the factory-opened base is readable through CachingStore/policy wrappers (S3.4's forwarding list covers ConditionalWriter, not the stamp), nor which concrete mechanism carries it (field per store type? optional ModeStamped interface? side registry keyed by store identity?). → **fix:** Specify the mechanism in S2-T10: e.g., an optional `interface{ ConditionalWritesMode() rollout.Mode }` implemented by every concrete store from a StoreOpenOptions field, forwarded by CachingStore and every wrapper in the S3.4 list (add stamp forwarding to those tasks' ACs), with ResolveConditionalWriter unwrapping via the same discipline as the capability assert. Add a test resolving through the REAL main composition (factory → NewCachingStore → policy wrap) asserting the stamped mode survives to the outermost value. +- [HIGH] (S2-T10 / S3.7) Factory-bypassing production store constructors are un-inventoried, so their resolved mode and writer-set membership are undefined. Verified bypasses in cmd/gc: cmd_hook_claim.go:579 (beads.NewBdStore — the worker claim WRITE path), scoped_store.go:27/:36 (city/rig scoped stores used by CLI mutations), bd_env.go:60/:82/:179/:198, cmd_bd_store_bridge.go:142, main.go:1136 (OpenFileStore), cmd_start.go:890 (MemStore). These are exactly the 'every short-lived gc CLI process on the host' writers the runbook's mixed-writer invariant warns about, yet no task pins that an unstamped store resolves Off, decides which of these should be stamped, or feeds the doctor multi-writer story. → **fix:** Add an S2-T10 subtask: grep-inventory every production beads.New*/Open* call site outside the factory, classify each (CAS-relevant writer vs read-only vs test-only), pin by unit test that an unstamped store resolves mode Off (never panics, never Auto), route the writers that matter (hook-claim, scoped stores) through the factory or stamp them explicitly, and cite the inventory from S3.7's runbook mixed-writer section instead of the current hand-wave. +- [MEDIUM] (S2-T9 / S3.3) sqlite_store_conditional.go is authored twice: S2-T9 and S3.3 are near-duplicate tasks with the same content (single conditional UPDATE with WHERE-clause guard, COALESCE claim-if-unset, revision column migration in applySchema, upsertBeadTx bump, the *IfMatch trio, unit-CI conformance row, mixed-binary ABA doc note). Two stages owning one artifact guarantees drift between the S2 and S3 PRs (which lineage carries it, whose AC gates it) and double-spends the work. → **fix:** Make exactly one task the author. Cleanest: move authorship wholly into S3.3 (it is S3's BLOCKING gate anyway) and reduce S2-T9 to defining the conformance-suite registration hook plus the early cherry-pick-cleanliness verification the S2 risk note asks for; or keep S2-T9 as author and demote S3.3 to rebase-verify + integration wiring. Update both stages' EXIT lines to name the single owner. +- [MEDIUM] (S2-T11 / S3.0 / S3.6) Cross-stage contradiction on degraded-event emission and payload shape. S2-T11 implements emission ('the ResolveConditionalWriter seam fires it exactly once per store instance', test TestDegradedEventLatchedOncePerStore) while S3.0's AC requires 'emission call count in the tree is zero (S2 registered, S3 emits)' and S3.6 re-implements emission via the factory callback. The payload also differs: S2-T11 registers {Store, Mode, Reason, BDVersion} but S3.6 emits store_id, store_kind, mode, origin, reason, bd_version — Origin and StoreKind are missing at registration time, and adding them later re-triggers the spec regen a second time. → **fix:** Assign emission to exactly one stage (S3.6's factory-injected nil-safe callback is the layering-correct design; make S2-T11 registration-only and delete its emission AC/test, satisfying S3.0's entry bar). Register the FULL S3.6 field set (incl. Origin, StoreKind) in S2-T11 so the payload schema is regen'd once. +- [MEDIUM] (S3.6) 'Declared multi-writer topology' has no defined detection predicate anywhere in the plan. S3.6 renders the mixed-writer WARNING on (auto ∧ DEGRADED ∧ declared multi-writer topology) and the design (DESIGN.md:2541) additionally mandates a doctor ERROR (block) for Require + multi-writer topology with any non-CAS writer — but no task defines what config field or derivation makes a topology 'declared multi-writer' (multiple rig stores? MemberStores configured? CLI writers, which are unconditionally present on every host?), and S3.6's exit-contract test cells cover require∧incapable but not the require+multi-writer+non-CAS-writer ERROR cell. → **fix:** Add an S3.6 subtask pinning the predicate concretely (e.g., more than one resolved writer store for a CAS-target ledger: work store ≠ graph store, or any per-rig member store; document that host-local CLI writers are assumed always-present and covered by the runbook rule instead) and extend TestDoctorRolloutExitContract with the require+multi-writer+non-CAS-writer → ERROR nonzero cell the design promotes to a blocker. +- [LOW] (S5-T2) cmd/gc/cmd_sling.go is missing from S5-T2's files/inventory even though cmd_sling.go:247 is one of the seven applyFeatureFlags call sites deleted in S5-T5; its molecule.Options feeder (the sling CLI path into sling.InstantiateSlingFormula) is covered only by the phrase 'plus sling/dispatch feeders'. If that Options construction site is missed, T5's deletion leaves the sling CLI compiling but silently on registry-default rather than the city's resolved flag. → **fix:** Name the cmd_sling.go Options-construction site explicitly in S5-T2's file list and production-inventory AC, and add a cmd-level entry-point test (city.toml fixture with formula_v2=false) through the gc sling path mirroring the cmd_formula one already specified. +- [LOW] (S2-T12) S2-T12 introduces an env-pinned bd binary path for the integration row ('GC_TEST_BD (or the contract-system equivalent)') while its own AC asserts 'no new GC_* env reads (S1 frozen baseline test green)'. If the baseline test covers test harness code, these two ACs conflict; if it doesn't, the exemption is unstated. → **fix:** Resolve within the task: either reuse the existing contract-test system's binary-pinning mechanism (internal/beads/contract, PR #3714 scaffolding) instead of minting a new GC_* var, or state explicitly that the frozen baseline covers production reads only and register the test-only var in whatever ledger the baseline test exempts. + +### sequencing-dependencies — SERIOUS_GAPS +- [BLOCKER] (S2-T1 / S4-T5) Bead.Revision is double-assigned across stages and its S2 form breaks S2's mergeability. S2-T1 adds `Revision int64 `json:"revision,omitempty"`` to internal/beads.Bead with the AC 'internal/api/openapi.json untouched', but API wire types embed beads.Bead directly (handler_beads.go:374-375 Root/Beads, huma_handlers_beads.go:452 Children, event_payloads.go:233 Bead beads.Bead — all verified), so a json-tagged field reds TestOpenAPISpecInSync and drifts events.json IN S2, contradicting the plan's claim that the wire change rides S4 atomically. Meanwhile S4-T5 re-specifies adding the same field and says S2 'read Current off PreconditionFailedError as the revision oracle' — directly contradicting S2-T1. Two stages own one deliverable with incompatible ACs. → **fix:** Remove the exported json-tagged field from S2-T1. In S2, BdStore decodes bd's revision via a bdstore-private decode envelope (or an untagged/unexported field) sufficient for the S2-T7 emulation loop, and the conformance suite observes revision through that store-internal surface / PreconditionFailedError.Current — exactly the workaround S4-T5 already assumes existed. The exported Bead.Revision + spec regen then land exactly once in S4-T5/S4-T7. Update S2-T1's AC and the S2 RISKS paragraph accordingly. +- [BLOCKER] (S2-T9 / S3.3) S2-T9 and S3.3 are the same deliverable specified twice in full: both author internal/beads/sqlite_store_conditional.go (CompareAndSetMetadataKey single conditional UPDATE, revision-keyed trio, applySchema revision migration, upsertBeadTx bump, unit-CI conformance row), both new-file-only on deploy/sqlite-b36-probe-attribution. Worse, S2-T9 cannot be part of S2's origin/main PR at all — origin/main has no SQLiteStore (verified), so a file referencing *SQLiteStore cannot compile there — yet S2's EXIT lists 'SQLiteStore on the lineage where it compiles' as an S2 deliverable, making S2 a hidden dual-lineage stage with no choreography task (S3.8 has one; S2 does not). → **fix:** Delete S2-T9 and fold it entirely into S3.3, where its consumers (S3.4 wrapper transparency, S3.5 blocking integration test) already sit in the dependency graph and where S3.8's dual-lineage choreography exists. Drop the sqlite row from S2's EXIT criteria; S2 ships main-only. If early de-risking of the deploy-lineage cherry-pick is wanted, make it a spike note in S2, not a build task. +- [BLOCKER] (S3 (pre-S3.3)) No task sequences getting the S1+S2 prerequisites onto the deploy lineage before the stage-3 blocking gate must be green there. Verified: deploy/sqlite-b36-probe-attribution's merge-base with origin/main is 220 commits behind, and the branch contains zero ConditionalWriter surface. sqlite_store_conditional.go (S3.3) cannot compile on deploy without S2's interface/typed-errors/beadstest harness; S3.5's ResolvedPathCapability leg and S3.8's smoke (drain + Attach through dispatch with conditional_writes=auto) additionally need S1's rollout package, the [beads] conditional_writes field, the factory mode-stamp, and the S3 consumer code. S3.8 only says 'plus a cherry-pick of the main PR' (the S3 PR) — S1 and S2 are never ported, and cherry-picking S3's drain.go/molecule.go/control.go edits across 220 commits of drift (S19 #4034, S34 #4043, S36 #4038 all touched those areas) will conflict heavily, undermining every 'applies clean' AC. → **fix:** Add an explicit predecessor task (S3.0b, blocking S3.3/S3.4a/S3.5): merge origin/main (carrying merged S1+S2) into deploy/sqlite-b36-probe-attribution — or cut a fresh deploy branch off current main and re-apply the sqlite commit stack — then run the S1/S2 suites green on the synced lineage before any S3 deploy-side work starts. Re-anchor S3.8's cherry-pick ACs and S2/S3 'applies clean' claims to the SYNCED lineage, and name the sync commit in the S3.8 checklist. +- [HIGH] (S2-T11 / S3.0 / S3.6) Degraded-event emission is built twice with contradictory acceptance criteria across the stage boundary. S2-T11's title and tests implement 'once-per-store latched emission' at the ResolveConditionalWriter seam (files include internal/beads/conditional_resolve.go; test asserts emission via fake bus), but S3.0's AC requires 'emission call count in the tree is zero (S2 registered, S3 emits)', and S3.6 then builds emission a second way (factory-injected OpenOptions.OnConditionalWritesDegraded callback wired at both composition roots). Verified: internal/beads imports no event bus today (caching_store_events.go consumes raw payloads only), so S2-T11's in-seam emission would also violate the Layer-0 side-effect confinement the plan itself cites. → **fix:** Make S2-T11 registration-only: event constant + typed payload + TestEveryKnownEventTypeHasRegisteredPayload, with the resolve seam returning a Diag carrying the degrade reason (no bus, no callback). Move the entire emission mechanism — callback definition, once-per-store latch test, wiring at controller and API-server roots — into S3.6, which already specifies it correctly. Reword S2's EXIT line ('registered ... and latched once per store' → 'registered; emission lands in S3') so S3.0's zero-emission entry assertion is satisfiable. +- [HIGH] (S1 / S4-T2) S1's independent mergeability has an unresolved hole the later stages assume away: the registry makes Expires + VersionAnchor MANDATORY for infra-rollout Specs, and S1 registers the beads CAS Spec on day one — but the anchor value (first #4682-carrying bd release) does not exist until S4-T0/T2 discovers it. The plan handles the graduation test's dormancy (reads the deps.env key, no-ops while absent) but never says what the Spec's mandatory VersionAnchor field holds between S1 and S4-T2. If S1's registry validation enforces a concrete anchor, S1 either can't merge until beads tags a release (breaking 'S1 stands alone') or ships an invented value that S4-T2 must silently correct. → **fix:** Specify the pre-tag representation in S1's registry contract: VersionAnchor for beads.conditional_writes names the deps.env key (BD_CONDITIONAL_WRITES_MIN_VERSION) and registry validation accepts key-absent-in-deps.env as an explicit 'anchor pending' state (distinct from missing/empty, which stays a registration failure). S4-T2's AC then includes flipping pending→concrete, and TestConditionalWritesGraduation's dormant path is keyed on the same condition — one definition, both stages consistent. +- [MEDIUM] (S2-T12) S2-T12's declared deps (S2-T6, S2-T7) don't cover what its exit sweep asserts: the T8 CachingStore livelock MERGE GATE, T10's mode-stamp/boundary re-check ('internal/rollout retains zero beads imports'), and T11's event registration are all in the sweep's green-list, but a dep-ordered scheduler can start T12 before T8/T10/T11 exist. Same class of slip as S3.6's dep list omitting the factory-callback surface it wires. → **fix:** Set S2-T12 deps to S2-T6, S2-T7, S2-T8, S2-T10, S2-T11 (T9 removed per the duplication fix). Additionally split the S1-dependent tail explicitly: T1–T8 are mergeable pre-S1 (the plan admits this only in RISKS); declare S2 as two PRs — S2a (T1–T8) and S2b (T10–T12, blocked on S1) — so the stage's 'independently mergeable' claim is structurally true rather than a footnote. +- [MEDIUM] (S4-T5) S4-T5 is a PR-B (origin/main) task whose files list includes 'internal/beads/sqlite_store_conditional.go (deploy-lineage revision read, if S3 left it pending)' — a file that does not exist and cannot compile on origin/main. A main-targeted PR cannot carry a deploy-lineage-only edit; as written the task silently requires the S3-style dual-PR choreography that S4 never sets up (S4 has no dual-lineage landing task at all, even though T5's conformance extension and the sqlite revision-read both need a deploy-side landing). → **fix:** Remove the sqlite file from S4-T5's files list and add an explicit deploy-lineage follow-up commit/task to S4 (mirroring S3.8's choreography): after PR-B merges to main, port the conformance-suite revision extension + sqlite revision-read to deploy/sqlite-b36-probe-attribution (or, if the lineage-sync fix from the S3 gap has main merging into deploy regularly, name that sync as the delivery vehicle and assert the sqlite conformance row green post-sync). +- [MEDIUM] (S4-T12) The Off→Auto default flip's hard precondition — the S3.8-initiated >=1-week maintainer-city soak with zero graph-store degraded events — exists only as AC prose; the task's dependency edge is just S4-T11. Nothing in the dependency graph prevents executing T12 the day PR-B merges, and the soak is tracked in a bead created by a different stage's post-merge step (S3.8), which a scheduler working from task deps will never consult. → **fix:** Add an explicit cross-stage dependency to S4-T12: 'deps: S4-T11, S3.8-soak-complete', where soak-complete is defined as the S3.8 soak-tracking bead closed with the recorded evidence (zero graph-store degraded events over the window, doctor graph=capable). Make S3.8's post-merge soak initiation emit that bead ID into the stage tracker so T12's gate is mechanically checkable. +- [LOW] (S5 (vs S3)) S5's dependency graph roots only at S1 (S5-T1 has empty deps), so nothing prevents running S5 in parallel with S3 — but S5-T2/T4 edit molecule.Options, dispatch ProcessOptions, drain.go, ralph.go, and molecule.go in the same regions S3.1/S3.2 rewrite for CAS (reserveDrainMember, Instantiate, control.go), and S5-T5 deletes internal/formulatest while S3/S4 feature branches may still import it. The plan orders S5 last narratively and notes 'coordinate with in-flight branches', but the constraint is prose, not a dependency. → **fix:** Declare the ordering as a dependency: S5-T2 blocked by the S3 main PR merge (drain.go/molecule.go stability), and S5-T5 blocked by 'no open feature branch imports internal/formulatest' as a checklist AC. Alternatively, if parallelism is wanted, assign rebase ownership explicitly in S5-T3's flag-day plan (it already mandates worktree-isolated subagents — extend that to name S3 as the rebase-against target). + +### testing-verification — SERIOUS_GAPS +- [BLOCKER] (S1 (absent)) The plan contains no S1 stage, yet S2-S5 consume ~10 S1 test artifacts by name that do not exist in the tree (verified: no internal/rollout/, no RetiredKey mechanism in internal/config/undecoded.go, no TestConditionalWritesGraduation, no cmd/gc/legacy_flag_freeze_test.go, no GC_* env-read baseline test). The lens's core teeth — the fragment-merge IsDefined downgrade regression test, the internal/rollout zero-beads-imports boundary test, the past-due-expiry CI failure, the env break-glass WARN-AND-USE-CONFIG malformed-value test, rollout.ForTest DI seam, the pending-restart notice, the doctor Rollout section — are never specified as concrete failing-first tests anywhere in this document. Every downstream AC that says 'S1 boundary test green' or 'the mechanism S1 shipped' is currently unfalsifiable. → **fix:** Author the S1 task list with the same task/AC/tests rigor before S2 work starts: one task each for (a) the import-boundary test (grep/go-list based, red against a synthetic beads import in internal/rollout), (b) the compose.go:1030-1047-shaped IsDefined preservation branch for [beads] conditional_writes plus a hand-written merge regression test proven red against whole-table LWW, (c) the expiry tooth (registry test that fails on Expires < build anchor, red-tested with a synthetic past-due Spec), (d) TestConditionalWritesGraduation dormant/armed both proven, (e) the frozen GC_* env baseline with an explicit statement of whether _test.go files are exempt, (f) undecoded.go RetiredKey. If S1 is deliberately reviewed in a sibling plan, this plan must cite that document and every cross-referenced artifact name must be validated against it verbatim. +- [HIGH] (S3.2) The C4 loser-convergence mechanism is contradicted by the actual code: internal/molecule/molecule.go:343-344 shows findExistingAttach RETURNS AN ERROR on any candidate root carrying molecule_failed=true — it does not skip and continue to the winner (the skip-and-continue guard at :422 is a different function, the step-reuse mapper). Since winner and loser share the same idempotency key + rootBeadID, the plan's own merge-gate test TestAttachEpochFence_ConcurrentAttach ('a third re-entrant call returns the winner via findExistingAttach') will fail — or worse, pass/fail non-deterministically depending on store List enumeration order (a latent flake). → **fix:** Add to S3.2's scope an explicit change to the molecule.go:343 guard: `continue` past molecule_failed candidates and only error (or return not-found) when no live candidate exists; then extend TestAttachEpochFence_ConcurrentAttach to seed both enumeration orders (loser-first and winner-first in the List result) so order-dependence is pinned. Without this the 'zero new recovery machinery' claim is false and the stage-3 merge gate is unpassable as written. +- [HIGH] (S2-T1 / S4-T5) Bead.Revision is double-specified and the two stages' TDD stories contradict each other: S2-T1(a) adds `Revision int64` to Bead with bd-JSON decode and S2-T3 makes Mem/File bump it (required — S2-T2's every_mutation_bumps_revision subtest needs the field as its observable oracle); S4-T5 then says 'add Revision to the domain Bead' again at line ~50, claims the tests go 'red on all stores initially', and says to 'retire the S2 workaround where the conformance suite read Current off PreconditionFailedError as the revision oracle' — a workaround that cannot exist if S2-T1 landed the field. One stage's red-first claim is fiction, and a reviewer cannot tell which tests are new versus already green. → **fix:** Resolve to one owner: keep the field, decode tolerance, and Mem/File/Caching bump discipline in S2 exactly as S2-T1/T3 specify; rewrite S4-T5 to contain only the genuinely new S4 work — BdStore decode validated against the released bd's real JSON (replacing S2's fixture-only coverage), native_dolt_store.go Issue.Revision mapping (needs the go.mod bump), the sqlite column read, and the CachingStore stale-ETag-after-412 leg — and delete the field re-add plus the 'retire the oracle' language. +- [HIGH] (S2-T9 / S3.3) S2-T9 and S3.3 both author the identical deliverable — internal/beads/sqlite_store_conditional.go on deploy/sqlite-b36-probe-attribution, with the same WHERE-clause guard, the same applySchema revision migration, and duplicated named tests (S2-T9's TestSQLiteCASEpochFenceExclusion, 8 goroutines CAS 3-to-4, is the same test as S3.5 leg 2 EpochFenceExclusionInProcess; TestSQLiteRevisionMigrationIdempotent appears in both stages). Two stages claiming to build one file on one branch makes test ownership ambiguous: which PR carries the conformance row, which carries migration idempotence, and whether S3.3 is a re-implementation or a verification is undecidable from the plan. → **fix:** Pick a single owner. Cleanest: delete S2-T9 entirely (S2's exit criteria drop the 'SQLiteStore where it compiles' clause) and let S3.3 own authoring, since S3.4/S3.5 depend on it and land on the same lineage PR; or keep S2-T9 as the authoring task and recast S3.3 as 'verify S2-T9's artifact still cherry-picks clean and extend with the in-tx re-read PreconditionFailed trio test'. Either way, every named sqlite test must appear in exactly one task's test list. +- [HIGH] (S2-T2/T3) The revision-bump conformance matrix has no completeness enforcement. The verified Store interface mutation surface is Create, Update, Close, Reopen, CloseAll, SetMetadata, SetMetadataBatch, Tx, Delete, DepAdd, DepRemove — plus satisfied optional write interfaces (ReleaseIfCurrent, claim surfaces, and GraphApplyFor: sqlite_store_graph_apply.go exists on the deploy lineage and molecule.Instantiate/ralph.go:417 route bulk creation through it). S2-T2's matrix names only update/labels/metadata/assign/close/reopen/CAS — CloseAll, DepAdd/DepRemove, Tx-surface writes, ReleaseIfCurrent, and GraphApply are unlisted, and whether a dep-edge change bumps the bead's revision is undefined. The plan's own RISKS section admits a missed write method 'trains consumer retry loops on stale revisions' but provides no test mechanism to catch one; 'audit ALL write methods' is a review instruction, not a test. → **fix:** Add a reflection-driven exhaustiveness subtest to RunConditionalWriterConformance: enumerate every method of beads.Store plus every write-bearing optional interface the store under test satisfies, and require each to appear in an explicit checked-in classification table (bumps / does-not-bump / exempt-with-reason); an unclassified method fails the harness. Decide and document dep-edge and GraphApply bump semantics in the ConditionalWriter doc-comment contract so BdStore, native, and sqlite cannot silently diverge. +- [MEDIUM] (S2-T11 / S3.6) The degraded-event emission architecture is specified twice, differently, so the once-per-store latch test gets written against one seam and rewritten against another: S2-T11 has emission fired 'from the composition layer off the returned Diag' at the ResolveConditionalWriter seam, while S3.6 has a factory-injected OpenOptions.OnConditionalWritesDegraded callback fired at the first capability veto, latched beside the capability latch. These differ in where the once-per-store guarantee lives and in when emission fires (resolve-time vs first-write veto); TestDegradedEventLatchedOncePerStore (S2-T11) and TestConditionalWritesDegradedLatchedOncePerStore (S3.6) are near-duplicates against different mechanisms. The existing precedent (caching_store_events.go, internal/beads has zero internal/events imports in non-test files — verified) supports the callback shape. → **fix:** Collapse to one design in S2-T11: the factory-injected nil-safe callback with the latch beside the capability latch (matching the caching_store_events.go precedent), write the once-per-store concurrent-hammer test once there, and reduce S3.6's emission work to wiring the callback to the bus at the two composition roots plus the two entry-point wiring tests. Delete the resolve-seam emission language from S2-T11. +- [MEDIUM] (S3.2) The mid-cleanup crash window is acknowledged in S3's RISKS ('Attach loser cleanup itself can crash mid-neutralization (after markFailed, before DepRemove)... must be exercised by the crash-retry test rather than assumed') but no task's test list contains that test: TestAttachEpochFence_LoserCleanupOrder asserts happy-path cleanup ordering, and the S3.2 AC's crash-retry leg fails 'after CAS-loss cleanup', i.e. after cleanup completed. The state left by the actual window — a molecule_failed root still holding a blocking edge onto the attach bead — is exactly the wedge the recovery machinery must clear, and it is untested. → **fix:** Add TestAttachEpochFence_CrashBetweenMarkFailedAndDepRemove to S3.2: a fault-injecting store double errors the DepRemove call once after markFailed succeeds; assert the subsequent level-triggered dispatch pass, via failedAttemptAttachRootID/partialAttemptAttachError (control.go:519-560), recovers the attempt and the attach bead does not wedge blocked on the failed root. +- [MEDIUM] (S3.5 / S3.8) The BLOCKING deployed-sqlite integration gate has no continuous execution venue. graph_store_sqlite_cas_test.go lives only on deploy/sqlite-b36-probe-attribution under //go:build integration, and the plan requires it 'green ON THE LINEAGE THE FLEET DEPLOYS FROM' — but never names which CI runs integration-tagged tests on that branch. If the deploy branch has no CI pipeline for it, the merge gate degrades to a one-shot local run whose evidence rots as the branch moves; 'evidence links (CI runs)' in the S3.8 checklist is unsatisfiable. → **fix:** Name the venue explicitly in S3.5/S3.8: either a nightly/rc-gate workflow job checked out on the deploy lineage running the documented integration shard targets, or a dedicated workflow added to the deploy branch in the same PR as the test. The S3.8 checklist item [4] must reference a repeatable CI artifact (workflow name + branch), not a pasted local run. +- [MEDIUM] (S2-T2) The conformance harness API cannot express its own disable_toggle row generically. Signature RunConditionalWriterConformance(t, open func(t) beads.Store) with the AC 'zero store-specific branches' conflicts with the disable_toggle_returns_typed_unsupported subtest: incapability is constructed differently per store (Mem/File field toggle, BdStore scripted-runner probe, sqlite has no toggle at all), and a single capable-store factory cannot produce the incapable variant. Either the harness grows store-specific branches (violating its AC) or the row silently no-ops for stores without a toggle. → **fix:** Give the harness an options struct with an optional openIncapable func(t *testing.T) beads.Store factory: Mem/File pass a Disable-toggled constructor, BdStore passes a scripted-incapable-runner constructor, sqlite passes nil and the row records a ledgered entry in beadstest/conformance_skips.go with a reason. Update the S2-T2 AC to say 'zero store-specific branches inside subtest bodies; store variance is expressed only through the factories'. +- [LOW] (S2-T8) The MERGE-GATE livelock regression requires 'post-write refresh Get scripted to fail once' on a MemStore-backed CachingStore, but MemStore has no fault-injection seam and the plan never specifies the failing-backing test double; beadstest/recording_store.go exists but nothing says it supports per-call scripted errors. The gate test is unimplementable as specified until that double exists. → **fix:** Add to S2-T8's deliverables: extend beadstest's recording store (or add a small failing-store double in beadstest) with per-call scripted errors keyed by method+call-count, and name it in the test list as the mechanism for the fail-once refresh Get and the injected PreconditionFailed. +- [LOW] (S2-T12) The integration row introduces an env-pinned bd binary path ('GC_TEST_BD or the contract-system equivalent') while the same task's exit sweep asserts 'no new GC_* env reads (S1 frozen baseline test green)'. Whether the baseline exempts _test.go files is unspecified (S1 absent), so the task potentially self-contradicts. → **fix:** Route binary pinning through the existing contract-test system's mechanism in internal/beads/contract/ (PR #3714 scaffolding) instead of a new GC_* variable, or state explicitly in the S1 baseline spec that the frozen inventory covers non-test files only and record the new test-only variable in the task's AC. +- [LOW] (S4-T7) Every event payload embedding beads.Bead silently gains `revision` on the events wire after regen; the RISKS section names downstream consumers (dashboard runs-view projection over events.jsonl, extmsg emitters) as 'needing a look' but no named test asserts cross-version decode tolerance in either direction (old consumer reading with-revision payloads; new consumer reading pre-revision fixtures). → **fix:** Add one round-trip case to the existing event payload contract suite (event_payload_contract_test.go / runview_roundtrip_test.go): decode a checked-in pre-revision events.jsonl fixture and a with-revision fixture through the runs-view projection and assert both succeed, pinning omitempty tolerance as a contract rather than an assumption. + +### general-auto-compliance — SERIOUS_GAPS +- [BLOCKER] (S1) The delivered plan contains NO S1 stage expansion. Every general-auto artifact the acceptance condition hinges on — the internal/rollout package, the GENERAL Mode+Capability resolver, the rollout.Capability type, the import-boundary test, ForTest options, ModeUnset semantics, the two day-one Specs — is consumed by name in ~40 places across S2–S5 (S2-T10 'Requires Stage 1', S4-T2 'S1's TestConditionalWritesGraduation', S5-T1 'S1 registered the daemon.formula_v2 Spec') but built by no task in this document. internal/rollout does not exist in the tree (verified), so as delivered the plan is unbuildable and the general core is unreviewable against the user's hard requirement. → **fix:** Deliver the S1 task-by-task expansion in the same plan document (or splice the existing one in and re-run this lens over it). It must contain named tasks for: (a) rollout.Capability type per DESIGN line 2542 (func(ctx) (capable bool, reason string) or small interface); (b) an EXPORTED general capability-resolve function (e.g. rollout.ResolveCapability(mode Mode, cap Capability) Decision) — not just Mode data; (c) the import-boundary test; (d) both Spec registrations; (e) ModeUnset zero-value semantics (DESIGN line 863). +- [BLOCKER] (S1/S2-T10) No task anywhere in the plan proves a NON-beads flag can use Auto — the user's acceptance condition asks for exactly this artifact. At plan completion the registry has four consumers, but the three non-beads ones (daemon.formula_v2, dolt.auto_gc_enabled, events.rotation.enabled) are all predicate-less; S5-T1 only tests that the capability leg is VACUOUS for predicate-less flags. Every test of Auto∧capable / Auto∧incapable lives in internal/beads (S2-T10 conditional_resolve_test.go, S3's consumer matrices). The general resolver's Auto path therefore has zero non-beads coverage, and its de-facto specification becomes the beads adapter — beads-locked by test coverage even if the import boundary holds. → **fix:** Add one cheap task (in S1, or as an S2-T10 AC): internal/rollout/capability_test.go drives the exported resolve function with a SYNTHETIC non-beads predicate (e.g. a fake 'runtime provider supports nudge' closure returning (bool, reason)) through all mode×capability cells, asserting verdict + reason propagation + nil-predicate behavior, using only rollout types. Optionally register a synthetic tri-state Spec via the []Spec-parameter validator/ForTest path the design already provides ('subsystem tests use local synthetic registries'). Reference this test from the S2/S3 GENERAL_AUTO exit criteria instead of narrative claims. +- [HIGH] (S2-T10) Nothing pins WHERE the enable∧capable four-cell product is computed. S2-T10 says the adapter feeds the predicate 'into rollout's GENERAL resolve(enable, capability)', but every matrix test it specifies lives in internal/beads (conditional_resolve_test.go over MemStore), and DESIGN's own §6.4 (line ~871) still calls ResolveConditionalWriter 'the single tested composition point of policy × capability' — the pre-amendment framing. An implementer can put the Off/Auto/Require branching inside ResolveConditionalWriter, export only the Mode enum from rollout, and pass EVERY stated AC including the import-boundary test — silently violating 'thin adapter, not the general API'. → **fix:** In S2-T10: (a) name the general function in the task text (rollout.ResolveCapability(mode, cap) Decision, Decision ∈ {UseLegacy, UseNew, DegradeLoud, RefuseClosed} + reason) and move the cell-product table test into internal/rollout; (b) add an AC with teeth: 'ResolveConditionalWriter contains no rollout.Mode cell branching — a grep/AST guard asserts no comparison against rollout.ModeAuto/ModeRequire exists in internal/beads outside the factory stamp; the adapter only maps the rollout Decision onto (writer | nil+*BeadsDiagnostic | typed error)'. This also structurally blocks future in-package consumers from reading the stamped mode and re-implementing the product. +- [MEDIUM] (S5-T1) Predicate attachment point is ambiguous across the plan. S5-T1 tests 'capability hook never called when SPEC supplies none' — wording that implies Specs can carry capability predicates. A Spec-held predicate for beads CAS would require either a beads import in internal/rollout/registry.go (boundary violation) or global runtime predicate registration (violates the per-instance/no-global-mutable-state DI requirement), and cannot express PER-STORE capability at all (verdicts differ per store instance within one process). S2-T10 correctly supplies the predicate at resolve-call time; the two tasks contradict each other. → **fix:** Pin the shape in both tasks: rollout.Capability is supplied per-call by the consumer adapter, never stored on Spec. Add an AC (registry shape / S5-T1): 'Spec contains no func-valued fields — the registry is pure data' (also what keeps registry.go CODEOWNERS-reviewable and the graduation edits in S4-T4/T12 data-only). Reword S5-T1's test to: Resolve and typed accessors never invoke capability machinery for any flag; capability enters only through the explicit rollout capability-resolve call. +- [MEDIUM] (S2-T10/S3.1/S3.2) The canonical 'four-cell matrix' undertests the general Mode state space, and the incomplete template propagates into every consumer acceptance test. (a) Require∧capable is missing from ALL matrices (S2-T10 lists Off, Auto∧capable, Auto∧incapable, Require∧incapable; S3.1/S3.2 test the same four) — yet Require∧capable→writer is the cell operators actually run after graduation, and its interaction with a runtime latch trip (capable probe, then exit-13 mid-run under Require) is untested. (b) ModeUnset→Off-with-recorded-BeadsDiagnostic ('mode not threaded; defaulted to off', DESIGN line 863) appears nowhere in S2-T10's factory-stamp task, so an unthreaded open path becomes silently indistinguishable from deliberate Off — dropping the design's visibility tooth for exactly the erosion case where a new composition path forgets to thread the general mode. → **fix:** Expand the template to the full product: 3 modes × 2 capability + Unset row. S2-T10: add TestResolveConditionalWriterRequireCapable and TestFactoryStampUnsetMapsToOffWithDiagnostic (assert the PreflightGate/PreflightReason diagnostic is recorded). S3.1/S3.2: add the require∧capable→CAS leg and a require + mid-run latch-trip leg. Update every 'four-cell' AC/exit-criterion string so the stage gates demand the complete matrix. +- [MEDIUM] (S3.6/S4-T9) The effective-status vocabulary (off/active/degraded/fail_closed/pending_restart) and its worst-of aggregation (fail_closed > degraded > pending_restart > active > off) are defined twice on consumer surfaces — S3.6 hand-rolls it in doctor rendering, S4-T9 re-implements it as 'a pure function of (mode, per-store verdicts)' in internal/api — with no shared home. Meanwhile S3's GENERAL_AUTO section claims 'doctor's per-store verdict rows render from the registry Spec plus a generic per-store capability array, so the next capability-gated flag gets its rows for free' — a generality no task actually builds. The claim invites reviewers to accept generality that doesn't exist, and the duplication guarantees doctor/status-wire drift. → **fix:** Two consumers already exist (doctor + status wire), which meets the repo's no-premature-abstraction bar: define the EffectiveStatus enum and the worst-of aggregator once in internal/rollout (pure data + pure function, zero beads imports) in S3.6, and make S4-T9's AC 'Effective is computed by rollout.AggregateEffective — no second truth table in internal/api'. If the team prefers not to generalize yet, instead strike the 'rows for free' sentence from the S3 GENERAL_AUTO section so the plan's generality claims match its deliverables. +- [LOW] (S1/S2-T12/S4-T11) Every restatement of the boundary invariant is the narrow form 'internal/rollout imports zero internal/beads packages'. That prohibition-list shape misses beads-ADJACENT leaks: internal/beadmeta (metadata key constants), internal/dispatch, internal/molecule, or internal/events could be imported into rollout without failing the test, gradually specializing the general core toward its first consumer. → **fix:** Specify the boundary test in allowlist form: internal/rollout may import only stdlib + internal/config (+ internal/deps if version helpers are needed); anything else fails with the package named. Reference the allowlist form in the S2-T12, S4-T11, and S5-T8 gate sweeps where the boundary test is re-run. + +### safety-rollback-compat — SERIOUS_GAPS +- [BLOCKER] (S2-T1 / S4-T5) S2-T1 adds `Revision int64 `json:"revision,omitempty"`` to beads.Bead while asserting 'internal/api/openapi.json untouched' — that AC is unsatisfiable. BeadGraphResponse (internal/api/handler_beads.go:374-375) embeds beads.Bead directly and is Huma-registered (huma_handlers_beads.go:345), so the json-tagged field drifts the OpenAPI schema and reds TestOpenAPISpecInSync in Stage 2, forcing an unplanned wire change two stages before the C2 PR — while the dashboard TS generator is still unrestorable (ga-iialk6, no regen config, hand-editing forbidden), so the leaked field cannot be faithfully propagated to TS until S4-T10. The plan also self-contradicts: S4-T5 re-adds the exact same field and says 'Retire the S2 workaround where the conformance suite read Current off PreconditionFailedError as the revision oracle', i.e. S4 was written assuming S2 did NOT put Revision on Bead. → **fix:** Rewrite S2-T1 to match S4-T5's assumption: no json-visible field on beads.Bead in Stage 2. Decode bd's revision on the existing bdIssue decode struct (bdstore.go:685 parseIssuesTolerant already decodes into bdIssue, not Bead) and hold revision as store-internal state (FileStore persists it in its own on-disk envelope; MemStore in its record; conformance suite reads it through a small unexported accessor or the PreconditionFailedError.Current oracle S4-T5 already names). The single first-class Bead.Revision addition plus spec regen happens exactly once, in S4-T5/T7. Alternative (choose one, explicitly): resequence the dashboard-TS-generator restoration (S4-T10) ahead of S2 and accept a deliberate additive spec regen in S2 — but then delete the 'no wire change' ACs from S2-T1/S2-T12 and drop the duplicate field-add from S4-T5. +- [HIGH] (S3.6) The locked design decision at DESIGN.md:2541 — 'Require mode + multi-writer topology containing any non-CAS-capable writer = gc doctor ERROR (block), not merely a warning' — is missing from the plan. S3.6 implements only require∧incapable-store→ERROR and the auto∧DEGRADED∧multi-writer WARNING. Under require with all resolved stores capable but a declared multi-writer topology containing non-CAS writers (older gc binaries, off-mode CLI nodes, a per-process env override), doctor renders green/ACTIVE while CAS mutual exclusion on gc.control_epoch / gc.exclusive_drain_reservation is void — the operator is told the fence holds exactly when it cannot. → **fix:** Add the require+declared-multi-writer+any-non-CAS-writer ERROR cell to S3.6's doctor aggregation and to TestDoctorRolloutExitContract (ERROR + nonzero exit), specifying how a non-CAS writer is determined from the declared topology (the same declaration the auto-mode warning consumes), and cross-reference it from the S3.7 runbook's mixed-writer invariant block. +- [HIGH] (S4-T0 / S4-T1) No rollback path or schema-migration assessment for the bundled-bd pin bump. S4-T1 calls itself 'pin motion only, no behavior change' and S4-T0 verifies only the CAS surfaces (--if-revision flags, exit bodies, Issue.Revision). But this repo has two recorded incidents of exactly this class: bd schema skew killed the control dispatcher (phantom wisps), and a bd downgrade across the v53/v54 schema boundary broke every worker claim. A #4682-carrying bd will very likely migrate .beads databases (revision column) on first contact; reverting PR-A afterwards points the fleet at a bd that hard-fails on the migrated schema — meaning the stage's only implied rollback (revert deps.env) is a fleet outage, not a rollback. → **fix:** Extend S4-T0's verification note with: (a) the beads schema-version delta between v1.1.0 and ; (b) empirical old-bd-vs--migrated-DB behavior (does v1.1.0 read or refuse?); (c) whether migrates on read or only on write. Gate PR-A on a written rollback procedure in the runbook: either 'pin revert is safe until first write, unsafe after — roll forward or hand-migrate with BD_IGNORE_SCHEMA_SKEW per the maintainer-city incident playbook', or a tested downgrade recipe. Add the same note to the S4-T1 PR description checklist. +- [HIGH] (S4-T8) If-Match presented on bead mutations that are not CAS-wired is silently ignored — an unconditional write under a presented precondition, the exact class the design's four-row rule forbids ('never a silent unconditional write'). Huma drops undeclared header params, so POST reopen (BeadReopenInput, deliberately excluded from the four verbs) — and create — with If-Match executes unconditionally while the client believes the precondition was honored. The plan records the reopen exclusion 'as a decision, not an omission' but builds no rejection surface for it. → **fix:** In S4-T8: declare If-Match on BeadReopenInput (and any other bead-mutating operation outside the four verbs) and return the 501 conditional_writes_unsupported problem (or 422) whenever it is present, so exclusion is loud instead of silent. Add a guard test that enumerates every registered bead mutation operation and asserts each either implements the full four-row semantics or rejects a presented If-Match — so a future mutation endpoint cannot silently join the ignore set. +- [MEDIUM] (S3.2) Exhaustion-class handling at the C4 epoch fence is unspecified. BdStore's CompareAndSetMetadataKey can return CASRetriesExhaustedError under cross-key revision churn on metadata-hot control beads (S2-T7), and the design pins 'consumers treat exhaustion as transient'. S3.1 wires that for C6, but S3.2 defines the C4 loser path only for ok==false: if exhaustion after Instantiate+DepAdd is mishandled as a loss, Attach marks its own (never-actually-conflicted) sub-DAG molecule_failed and burns work; if surfaced unclassified, whether the partial attempt lands as transient-retry or hard-for-attempt is undefined — precisely the ambiguity that trains retry loops wrong on the fleet's hottest path. → **fix:** Pin in S3.2: CASRetriesExhaustedError (and every non-PreconditionFailed error) at the epoch CAS does NOT run the loser-neutralization walk; it surfaces as transient so the level-triggered pass re-enters and converges via findExistingAttach (which runs before the fence). Add a scripted cross-key-churn test row: exhaustion at the fence → no molecule_failed stamp on the just-created sub-DAG, retry returns the existing sub-DAG, exactly one live sub-DAG. +- [MEDIUM] (S5-T7) The graph_workflows tombstone silently flips live operator configs from the v1 sequential transport to v2 graph-apply. Verified: today graph_workflows=false with formula_v2 absent resolves FormulaV2=false (config.go:4290-4295 honors the alias). After the tombstone the key is warn-and-ignored, so the identical config file resolves formula_v2=true (default) on upgrade — a runtime transport change guarded only by a one-line load warning that unattended fleets never read. 'Existing configs/behavior preserved' fails for exactly the operators who set the alias to opt OUT. → **fix:** Make the RetiredKey warning value-aware in S5-T7: graph_workflows=true (matches the effective default) → plain retirement warning; graph_workflows=false (behavior-changing) → surface at doctor-ERROR / pending-restart-notice grade, or take a one-version-anchor fatal-load window for the non-default value forcing an explicit move to formula_v2=false. Keep the release note S5-T8 already carries, but don't let the note be the only surface. +- [MEDIUM] (S4-T3) Deleting the four-verb help-grep in favor of ProbeBDVersion + CompareVersions silently degrades source-built bd fleets. The maintainer fleet's documented recovery path is rebuilding bd from /data/projects/beads main (memory: bd-downgrade-v54 fix), and this fleet deploys branches, not tags — a source-built bd frequently reports an rc/dev/below-floor version string while actually supporting --if-revision. After T3, deps.CompareVersions(, floor) < 0 → probe incapable → an active CAS fleet flips back to legacy (auto, silent past one degraded event) or to refused writes (require) after a routine bd rebuild. The interim help-grep would have said capable; the plan never tests the suffixed-version rows. → **fix:** Add explicit test rows to S4-T3 for rc/dev/suffixed version strings ('1.2.0-rc.1' vs floor '1.2.0', '+dev' builds, empty/gibberish) pinning the deps.CompareVersions verdict per row; document in the S3.7/S4 runbook what version a source-built bd must report to clear the floor (and that the fix for a false-incapable verdict is rebuilding with a proper version stamp + restart, legible in doctor as probe=incapable). +- [MEDIUM] (S4-T11) No rollback story for the C2 wire surface. If PR-B is reverted (or a fleet rolls back to a pre-S4 gc) after clients have adopted ETag/If-Match, the server no longer declares the If-Match param, Huma silently drops it, and every conditional client write executes unconditionally with no 412/501 — reintroducing the silent lost-update class the whole subsystem exists to kill, now with clients actively believing they are protected. → **fix:** In S4-T11's PR body and the runbook: pin the client contract (send If-Match only when the immediately-preceding GET carried an ETag; treat ETag absence as CAS-unsupported and fall back to read-verify) so a reverted/downgraded server — which also stops emitting ETag — fails safe for conforming clients; state the PR-B revert procedure explicitly (revert order vs PR-A, spec/dashboard regen on revert); and assert the contract in the generated TS client docs or a genclient test. +- [LOW] (S2-T11 / S3.6) Two different emission mechanisms for beads.conditional_writes.degraded across consecutive stages: S2-T11 has ResolveConditionalWriter return a Diag with 'the composition layer publishes', while S3.6 introduces a factory-injected nil-safe OpenOptions.OnConditionalWritesDegraded callback wired at both roots. Building the Diag-return shape in S2 and rebuilding it as a factory callback in S3 churns the Layer-0 boundary twice and risks a window where one composition root emits and the other doesn't. → **fix:** Pick the factory-injected callback shape once, in S2-T11 (it is the shape S3.6's TestOpenStoreWiresDegradedCallbackAtBothRoots asserts), leaving S3.6 as wiring-plus-tests only; note in S2-T11 that CLI paths without a bus pass a nil callback and rely on the structured log. +- [LOW] (S4-T12) No defined emergency un-flip path for the Off→Auto default. After T12 (Default=Auto, GraduatedIn set, FlipDueBy cleared), an incident during the Auto-default window has no stated rollback: reverting registry.go reds TestConditionalWritesGraduation (anchor past floor, Default Off) unless FlipDueBy is re-set, and the plan never says whether the first-line mitigation is a registry revert or a config override. → **fix:** One sentence in S4-T12's AC + the runbook: the emergency path is fleet config, not a registry edit — set [beads] conditional_writes="off" in city.toml + controller restart (config precedence beats the registry default, already supported per DESIGN.md:2045); a registry-level un-flip, if ever needed, must re-set FlipDueBy= in the same diff to stay green in CI. + +### buildability-yagni — SERIOUS_GAPS +- [BLOCKER] (S1) The execution plan contains NO stage-1 task breakdown at all — it starts at S2-conditionalwriter — yet at least nine downstream tasks consume named S1 artifacts, several of which appear nowhere in the plan's one-line S1 summary and do not exist in the tree today: TestConditionalWritesGraduation with readDotenv-keyed dormancy (S4-T2/T4 depend; grep shows it unbuilt), scripts/rolloutradar (S4-T4 AC and tests require it; not in scripts/), the RetiredKey/tombstone mechanism in internal/config/undecoded.go (S5-T7 says 'using the mechanism S1 shipped'; grep -c 'retired' undecoded.go = 0), internal/rollout/gc_env_baseline.go (S5-T6 file list), cmd/gc/doctor_rollout.go (S4-T9 calls it 'S1's doctor section'), cmd/gc/legacy_flag_freeze_test.go (S5-T5 deletes it 'from S1'), the pending-restart Notice + reload boot-latch (S5-T5 regression test), and State-carried boot Flags in internal/api (S4-T9/S5-T2). A builder starting S1 from the summary bullet would miss most of these and every later stage would stall on its entry preflight. → **fix:** Write the S1 stage as a real task-by-task plan (same format as S2–S5) whose deliverable inventory names each artifact exactly as downstream stages reference it: rollout package (Spec/registry/resolver/Origin/Notices/ForTest/LookupEnv seam), BeadsConfig.ConditionalWrites + mergeFragment preservation branch + 3-test merge regression trio (template: compose_test.go:82), registry-driven load-time enum validation incl. the bd_compatibility silent-normalize bugfix (a behavior change — release-note it), Resolve wiring at cmd/gc loaders (cmd_agent.go:39/61, cmd_start.go:989, cmd_sling.go:433, cmd_config.go:25) and internal/api New/NewReadOnly, reload boot-latch + pending-restart Notice, doctor_rollout.go section + exit-code contract, legacy_flag_freeze_test.go, gc_env_baseline.go, dormant TestConditionalWritesGraduation in the scripts/bd_version_pin_test.go family, the undecoded.go retiredKeys mechanism, the graph_workflows retirement-obligation bead, CODEOWNERS line, and the radar (or its explicit deferral — see separate gap). +- [HIGH] (S1) Stage 1 as scoped is not buildable as one focused PR. Even the design splits it (PR-1a/PR-1b, DESIGN.md:2400), and PR-1b alone spans four areas: a new ~1000+ LOC internal/rollout package with six registry test families; internal/config edits (field, accessor, ValidateSemantics walk, mergeFragment branch at compose.go:1030, bd_compatibility bugfix); cmd/gc threading across 5 loader helpers, 7 applyFeatureFlags-adjacent call sites, two reload paths (api_state.go:1808, controller.go:923), plus a new doctor section; an internal/api State change; AND two freeze teeth including a frozen GC_* env-read baseline that must enumerate 231 non-test read sites across 77 files (verified by grep). That is a multi-thousand-line, risk-heterogeneous diff no reviewer can hold, violating the plan's own 'each stage independently reviewable' constraint. → **fix:** Split S1 into four independently mergeable PRs and encode the split in the S1 task list: PR-1a prompt extraction (parallel, non-blocking); PR-1b internal/rollout package + internal/config field/accessor/validation/merge-branch + registry tests + import-boundary test + CODEOWNERS (no production wiring — flag inert, zero behavior change); PR-1c composition-root wiring (loaders + API server + reload latch + pending-restart Notice + doctor section + re-scoped entry-point tests); PR-1d lifecycle/freeze teeth (repo-walk golden list, GC_* frozen baseline, dormant graduation test, retiredKeys mechanism). The flag stays inert until PR-1c, so each PR is green and mergeable alone; S2-T1..T9 can start after PR-1b, only S2-T10 needs PR-1c. +- [HIGH] (S1 / S4-T12) The config-accessor import-cycle contradiction is unresolved and the plan propagates the broken side. DESIGN line 507 specifies `func (b BeadsConfig) ConditionalWritesMode() rollout.Mode`, which cannot compile because internal/rollout imports internal/config (Resolve takes *config.City) — STAGE1-CODEMAP.md:57 explicitly says 'flag this to implementers'. The plan never resolves it, and S4-T12's files line ('internal/config config accessor zero-value default test (BeadsConfig.ConditionalWritesMode)') repeats the accessor-on-config shape. A builder following the DESIGN sample hits an import cycle at best; at worst they mint a duplicate Mode enum inside internal/config, creating exactly the two-homes default drift the registry default-equality test exists to prevent. → **fix:** Pin the shape in the S1 task text: BeadsConfig gains a plain validated string field (+ optionally a config-local string normalizer mirroring NormalizedBDCompatibility at config.go:1401); the string→rollout.Mode mapping, the Flags.BeadsConditionalWrites() accessor, and the Default==zero-value-config equality test all live in internal/rollout. Correct S4-T12's file reference to the rollout-side accessor/test, and add a one-line errata note to DESIGN §4.1 (line 507) so no later builder re-imports the contradiction. +- [HIGH] (S1) The freeze-test scope is mis-specified in the direction the codemap explicitly warns about. The plan says freeze tests make 'the legacy cmd/gc/feature_flags.go mechanism un-copyable', but two of the four frozen symbols live outside cmd/gc (formula.SetFormulaV2Enabled in internal/formula/compile.go, molecule.SetGraphApplyEnabled in internal/molecule/graph_apply.go) and the duplicate root syncFeatureFlags is in internal/api/server.go:197/203/229 (verified). The named template, TestGCNonTestFilesStayOnWorkerBoundary, walks ONLY the cmd/gc directory via runtime.Caller — a byte-identical mirror silently leaves the api twin free to grow (STAGE1-CODEMAP.md:232/237). Two further guess-wrong traps: feature_flags.go:9's doc-comment mention makes a naive strings.Count baseline off-by-one, and the test-side ceiling is ~121 setter references across 7 packages, not the '~20 save/restore blocks' the plan header repeats. → **fix:** Specify in the S1 freeze task: (a) a repo-root walk with a checked-in inventory keyed by repo-relative path (cmd/gc files=7 call sites + 1 def, internal/api/server.go=3) — DESIGN.md:2076 already requires this shape; (b) an explicit counting rule (non-test .go files, comments excluded or the baseline states they count); (c) per-package COUNT CEILINGS for test files (formula 54, molecule 44, dispatch 8, cmd/gc 5, graphroute 4, api 4, bootstrap 2 — from the codemap), never a per-line inventory; (d) all four symbol needles plus the four formulatest wrapper names (LockV2/HoldV2/SetV2/EnableV2ForTest) so S5's grep-zero exit criterion has a matching freeze. +- [HIGH] (S4-T4) scripts/rolloutradar is consumed but never built. S4-T4's AC requires 'Radar (scripts/rolloutradar) run locally files/updates the pending-flip finding' and its tests include 'Radar unit test: FlipDueBy-pending finding class emitted' — but the tool does not exist (verified: no radar in scripts/), no task in S2–S5 creates it, and DESIGN.md:2400's stage-1 blob omits it too (the design only says the nightly radar files beads 'throughout'). As written, S4-T4 is unexecutable, and the doctor exit-contract line 'radar-surfaced past-due items render as ERRORS' (DESIGN:1144) is similarly ungrounded. For a registry holding two Specs whose Expires dates are in 2027, a nightly bead-filing radar built in S1 is also the plan's strongest YAGNI-cut candidate. → **fix:** Pick one and encode it: (a) add a small named task (S1-d or an S4-T4 predecessor) building scripts/rolloutradar with exactly three finding classes (past-due Expires, FlipDueBy-pending, tombstone past RemovedIn+1) plus the nightly workflow; or (b) defer the radar with a named owner bead and rewrite S4-T4's AC/tests to the minimal equivalent — doctor WARN on FlipDueBy-pending (doctor may compare wall-clock; only merge-blocking CI may not) plus a manually filed pending-flip bead linked in the PR body. Option (b) is the YAGNI-correct choice at N=2 flags; either way the current dangling reference must go. +- [MEDIUM] (S1 (PR-1a)) internal/prompt extraction does bloat stage 1 if it rides the rollout PR, and the plan over-commits it. It is a 507-line package-main file move (cmd/gc/prompt.go, verified) touching the agent-start render path (template_resolve.go), gc prime, and cmd_lint's inline re-implementation, with one non-mechanical seam (injecting lookupSessionNameOrLegacy, whose file drags internal/session transitively if moved). Nothing in S2–S5 depends on internal/prompt existing, and the extraction is DESIGN open question #5 (line 2548: 'do it, or defer' — i.e., NOT one of the 16 locked decisions), yet the plan header presents it as settled S1 scope whose absence blocks 'the prompt-boundary import test'. → **fix:** Keep PR-1a but sequence it as fully parallel and explicitly non-blocking: the S1 task list states that PR-1b/1c/1d and all of S2 proceed regardless of PR-1a's landing; the prompt-boundary import test lands in whichever of PR-1a/PR-1b merges second (it needs both packages to exist to be non-vacuous). Scope PR-1a to: move prompt.go verbatim, export PromptContext/Render/BuildTemplateData, add the injected session-name resolver param (impl stays in cmd/gc/session_name_lookup.go), convert the 3 construction sites + cmd_lint's inline path, ~6 files total. If the milestone runs long, the recorded fallback is DESIGN's own alternative: AST lint + review checklist, extraction deferred. +- [MEDIUM] (S1) S1 root-wiring semantics are under-specified in the exact way a builder guesses wrong: 'Resolve wired at BOTH composition roots' must mean ADDED ALONGSIDE applyFeatureFlags/syncFeatureFlags — the legacy calls survive frozen until S5-T5 (their call-site counts are pinned by the S1 freeze inventory, and S5-T5 owns their deletion plus the release-noted reload-latch behavior change). A builder reading the plan's heavy anti-pattern language could replace the legacy calls in S1, which (a) breaks the freeze baseline landing in the same stage, (b) silently ships S5's process-latch reload behavior change (controller.go:923 / api_state.go:1808 re-apply today) three stages early without the release note, and (c) invalidates S5-T5's red-first regression test which must fail 'against the pre-deletion re-apply behavior'. → **fix:** Add one sentence to the S1 wiring task and its AC: 'applyFeatureFlags/syncFeatureFlags call sites are untouched in S1 (the freeze inventory in this same stage pins them); rollout.Resolve runs beside them; only the rollout-resolved snapshot is boot-latched, and the daemon.formula_v2 global re-apply on reload keeps today's behavior until S5-T5.' Assert it with the freeze test itself (inventory unchanged) in the S1 exit gate. +- [MEDIUM] (S1) The plan header mis-states the expiry tooth as 'mandatory Expires with PAST-DUE CI FAILURE'. The design settled the opposite (DESIGN:1736, 2493): no merge-blocking check may compare against time.Now(); wall-clock Expires is radar/doctor-WARN only, hard-failing PR CI ONLY when registry.go itself is in the diff. A builder encoding the summary version creates a date bomb that reds every PR the day an Expires passes — the exact trivyignore-cliff fleet-stall pattern this repo's memory documents — and violates the deterministic-per-commit rule the graduation test was designed around. → **fix:** The S1 lifecycle-tooth task must specify two separate mechanisms verbatim from the design: (1) merge-blocking = the version-anchored two-stage graduation test (pure function of deps.env + registry.go) plus the registry.go-in-diff-gated expiry check; (2) wall-clock staleness = radar/doctor WARN only, never Check. Correct the summary line wherever the plan is stored so no S1 builder inherits it. +- [MEDIUM] (S1 / S4-T9) API-root Flags threading is under-specified. S4-T9 and S5-T2 assume 'the server State's boot-resolved Flags (S1)', but internal/api.State is an interface (state.go:59) exposing only Config() *config.City, implemented by cmd/gc's controllerState plus test fakes; New(state) currently derives flags by calling syncFeatureFlags(state.Config()) at server.go:197/203. S1 must choose a vehicle — extend the State interface with a Flags() accessor (ripples through controllerState and every fake) or add a construction-options path — and without pinning it, the natural shortcut is re-resolving from state.Config() inside handlers, which violates process-latching and breaks S4-T9's 'daemon's OWN latched snapshot, never a re-derivation' AC and the env-contradiction boundary test. → **fix:** Pin the vehicle in the S1 wiring task: State gains `RolloutFlags() rollout.Flags` (boot-resolved, latched, Notices retained), controllerState stores it beside cfg at boot, fakes return rollout.ForTest values; New/NewReadOnly read it once at construction. Name the fake-update cost in the task's files list so the PR sizing is honest, and add the entry-point test that a server constructed over a State whose on-disk config has since changed still serves the boot snapshot. +- [LOW] (S1) DESIGN §6.6 (line 890) says stage 1 lands one entry-point test per CAS-relevant command (controller, hook, sling, api server) 'each asserting that require in a real temp city.toml is observed at the bd wire by a probe write' — but in S1 there is no ConditionalWriter (S2) and no consumer (S3); stage 1's own gate says 'flag resolves but nothing consumes it'. An S1 builder implementing the design sentence literally is blocked; one who quietly drops entry-point tests loses the routeReadCmd-lesson coverage the design demands. → **fix:** The S1 task list re-scopes entry-point tests explicitly: in S1 each of the four commands is tested to the seam — temp city.toml with conditional_writes="require" produces a resolved Flags value with Mode=Require and Origin=config observed at the composition root (capture/stub) — and the bd-wire probe-write leg is deferred by name to S3's four-cell matrix tests, with a pointer in both stages so neither builder drops it. + +## Open risks + +- beads release timing (external, unmitigable in-repo): #4682 has no tagged release; PR-A and everything after slips with the tag. Bounded: steps 1–10 (S1, S2, S3, soak) are fully executable today, and BD_CURRENT_REF can pin a #4682 commit early for contract coverage. +- S3.0b lineage sync is a real merge of 220 commits of drift (S19/S34/S36 all touched dispatch/session areas the deploy branch diverges on) — it may surface conflicts that cost days and could destabilize the deploy branch the fleet runs; mitigation is the cut-fresh-branch-off-main alternative, but that requires re-validating the whole sqlite commit stack. +- The fleet-scoped mixed-writer invariant remains unenforceable in code: one node at off, one older binary, or one Auto-degraded host re-opens the races while doctor on the CAS node reads ACTIVE; the new require+multi-writer ERROR cell, the bypass inventory, and the runbook surface it but cannot prevent it — operator discipline is the real control. +- Cross-key revision churn on metadata-hot control beads under BdStore emulation: if the §8.4 bd-sql spike fails its revision-bump disqualifier, C6/C4 inherit exhaustion-as-transient behavior whose backoff may need live tuning during the soak; the soak window is the only place this is observable. +- S5-T3 remains a ~19-file flag-day PR racing main churn, and S3/S5 both edit drain.go/molecule.go — the dependency edges added reduce but do not eliminate rebase risk; the red-team-agents-on-shared-worktree residue hazard (project memory) applies to its subagent execution. +- First dashboard TS regen after restoring the lost openapi-ts config will produce a large stale-paydown diff that can mask a real schema regression; mitigated by isolated regen commits and diff triage, but reviewer attention is the actual gate. +- The two-knob mapping (bd CLI tag vs Go module tag, steveyegge vs gastownhall paths) is recorded in S4-T0 but TestBDVersionPins still does not assert go.mod — the stretch lockstep assertion is unowned; silent CLI/library divergence remains possible until someone adds it. +- Deferred radar: with scripts/rolloutradar cut to a doctor WARN + manual bead, lifecycle debt visibility depends on operators running doctor and on the FlipDueBy one-bump bound — a long gap between bd bumps stretches the window in which a pending flip can be forgotten without any nightly surfacing. +- Ambiguity-tolerance ordering in C4 (findExistingAttach before the fence) is comment-and-test enforced, not type-enforced; a future refactor reordering it silently voids the false-loss tolerance — the seam comments and crash-retry test are the only tripwires. diff --git a/engdocs/plans/feature-flags/STAGE1-CODEMAP.md b/engdocs/plans/feature-flags/STAGE1-CODEMAP.md new file mode 100644 index 0000000000..6b5bb90a04 --- /dev/null +++ b/engdocs/plans/feature-flags/STAGE1-CODEMAP.md @@ -0,0 +1,493 @@ +# Stage 1 code map — internal/rollout (Opus exploration) + +_Precise file:line touchpoints for building stage 1. Reference during TDD build._ + +## config-city-struct: internal/config — City / BeadsConfig, where [beads] conditional_writes belongs + +**Idiom to follow:** Add ConditionalWrites as a validated STRING field on BeadsConfig at internal/config/config.go:1377 immediately beside BDCompatibility, using the exact jsonschema enum tag idiom: `ConditionalWrites string `toml:"conditional_writes,omitempty" jsonschema:"enum=off,enum=auto,enum=require"``. Give it a nil/empty→default typed accessor mirroring NormalizedBDCompatibility (config.go:1401) and DaemonConfig.FormulaV2Enabled (config.go:4277): empty string maps to the built-in default (Off). CRITICAL import-direction constraint (DESIGN §"import direction", line 293): internal/rollout imports internal/config, so BeadsConfig CANNOT have a method returning rollout.Mode without a cycle — the accessor on BeadsConfig must return a plain string (or a config-local enum), and the string→rollout.Mode mapping + Flags.BeadsConditionalWrites() Mode read surface lives in internal/rollout. The DESIGN code sample at line 507 (`func (b BeadsConfig) ConditionalWritesMode() rollout.Mode`) contradicts its own line 293 and would not compile — flag this to implementers. For fragment-merge preservation, add an IsDefined branch mirroring the daemon.formula_v2 pattern at compose.go:1039-1045 (NOT the last-writer-wins beads branch at 1030-1032, which currently blows away the whole struct), plus a per-field default hook mirroring applyDaemonFormulaV2Default (config.go:4281) called from Parse (config.go:4258). Register nothing new in undecoded.go (BeadsConfig already listed at line 204). + +- **City struct — the root config; where the [beads] section is embedded** — `internal/config/config.go:209` + - shape: `type City struct { ... Beads BeadsConfig `toml:"beads,omitempty"` (line 247) ... Daemon DaemonConfig `toml:"daemon,omitempty"` (line 263) ... }` + - note: City.Beads at line 247 is the home for the new field's containing section. Note the comment at 246: '// Beads configures the bead store backend.' +- **BeadsConfig struct — the exact struct the new conditional_writes field belongs to** — `internal/config/config.go:1356` + - shape: `type BeadsConfig struct { + Provider string `toml:"provider,omitempty" jsonschema:"default=bd"` + Backend string `toml:"backend,omitempty"` + EventHooks *bool `toml:"event_hooks,omitempty" jsonschema:"default=true"` + BDCompatibility string `toml:"bd_compatibility,omitempty" jsonschema:"enum=bd-1.0.4,enum=bd-1.0.5"` + Policies map[string]BeadPolicyConfig `toml:"policies,omitempty"` + }` + - note: Add `ConditionalWrites string `toml:"conditional_writes,omitempty" jsonschema:"enum=off,enum=auto,enum=require"`` right after BDCompatibility (line 1377), per DESIGN line 491/480. +- **BDCompatibility field — the closest existing reference field (validated-string enum + typed accessor + nil/empty→default)** — `internal/config/config.go:1377` + - shape: `BDCompatibility string `toml:"bd_compatibility,omitempty" jsonschema:"enum=bd-1.0.4,enum=bd-1.0.5"`` + - note: This is the template to copy for tags. Its enum-value constants (BeadsBDCompatibility104/105) are declared at config.go:1390-1396 as package consts — mirror with rollout.Off/Auto/Require living in internal/rollout, not here. +- **NormalizedBDCompatibility — the empty/unknown→default typed-accessor idiom for a string enum** — `internal/config/config.go:1401` + - shape: `func (b BeadsConfig) NormalizedBDCompatibility() string { switch b.BDCompatibility { case "", BeadsBDCompatibility104: return BeadsBDCompatibility104; case BeadsBDCompatibility105: return BeadsBDCompatibility105; default: return BeadsBDCompatibility104 } }` + - note: Exact pattern for the conditional_writes accessor: empty string and unknown both collapse to the default (Off). DESIGN line 504-511 wants this on BeadsConfig but returning rollout.Mode — that breaks the import cycle; return string here and map in rollout. +- **DaemonConfig.FormulaV2 + FormulaV2Enabled — the *bool kill-switch (nil=default-on) idiom, cited by DESIGN as the model for bool-kind flags** — `internal/config/config.go:2294` + - shape: `FormulaV2 *bool `toml:"formula_v2,omitempty" jsonschema:"default=true"` (field, line 2294; deprecated alias GraphWorkflows bool at 2297) + func (d DaemonConfig) FormulaV2Enabled() bool { return d.FormulaV2 == nil || *d.FormulaV2 } (line 4277)` + - note: This is the SECOND registered Spec's config home (daemon.formula_v2, DESIGN Kind 2 / §279). Nil pointer = omitted-and-default; explicit false is an operator decision. Read via the accessor, never the raw pointer. +- **applyDaemonFormulaV2Default — post-decode default/alias-resolution hook; the pattern for a conditional_writes normalize hook** — `internal/config/config.go:4281` + - shape: `func applyDaemonFormulaV2Default(cfg *City, md toml.MetaData) { if cfg==nil {return}; if md.IsDefined("daemon","formula_v2") {return}; if md.IsDefined("daemon","graph_workflows") { v:=cfg.Daemon.GraphWorkflows; cfg.Daemon.FormulaV2=&v; return } }` + - note: Uses toml.MetaData.IsDefined(section, key) to distinguish absent vs explicit. Called from Parse at config.go:4258. A string field like conditional_writes needs no such hook (empty is a valid sentinel) unless normalizing; the accessor handles the default. +- **Parse — decode entry point where post-decode hooks run (one of the two composition roots to wire Resolve near)** — `internal/config/config.go:4251` + - shape: `func Parse(data []byte) (*City, error) { cfg := City{}; md,err := toml.Decode(string(data), &cfg); ...; normalizeAgentDefaultsAlias(&cfg,md); applyDaemonFormulaV2Default(&cfg,md); normalizeLegacyOrderOverrideAliases(&cfg); NormalizeSessionSleepFields(&cfg); ... }` + - note: md (toml.MetaData) carries IsDefined here. If conditional_writes needs alias/validation normalization it slots into this chain at line 4258-4260. +- **fragment-merge: the daemon.formula_v2 IsDefined PRESERVATION branch — the exact idiom to mirror for conditional_writes** — `internal/config/compose.go:1039` + - shape: `if fragMeta.IsDefined("daemon") { formulaV2 := base.Daemon.FormulaV2; base.Daemon = fragment.Daemon; if !fragMeta.IsDefined("daemon","formula_v2") && !fragMeta.IsDefined("daemon","graph_workflows") { base.Daemon.FormulaV2 = formulaV2 } }` + - note: THIS is the pattern the task's 'IsDefined fragment-merge preservation branch' must copy. The current beads branch (compose.go:1030-1032) is naive last-writer-wins: `if fragMeta.IsDefined("beads") { base.Beads = fragment.Beads }` — a fragment defining [beads] without conditional_writes would wipe a root-level conditional_writes. Rewrite it to save base.Beads.ConditionalWrites and restore it when !fragMeta.IsDefined("beads","conditional_writes"). +- **beads fragment-merge branch — current naive last-writer-wins that must be upgraded** — `internal/config/compose.go:1030` + - shape: `if fragMeta.IsDefined("beads") { base.Beads = fragment.Beads }` + - note: Merge function is mergeCitySection-style; fragMeta is toml.MetaData for the fragment. Add the save/restore preservation exactly like the daemon branch below it. +- **compose_test fragment-preservation regression tests — the template for the merge regression test the task requires** — `internal/config/compose_test.go:82` + - shape: `TestLoadWithIncludesPreservesExplicitFormulaV2FalseAcrossDaemonFragment(t) — root city.toml sets [daemon] formula_v2=false, fragment.toml sets only [daemon] patrol_interval; asserts cfg.Daemon.FormulaV2Enabled()==false AND fragment field survives. Also TestLoadWithIncludesDefaultsFormulaV2Enabled (line 49) and ...PreservesExplicitFormulaV2False (line 64).` + - note: Copy this three-test shape for conditional_writes: (1) omitted→default off; (2) explicit require survives; (3) root require survives a [beads] fragment that only sets e.g. bd_compatibility. +- **TestBeadsConfigRoundTripPreservesStagedFields — the Marshal/Parse round-trip test for BeadsConfig staged fields** — `internal/config/config_test.go:715` + - shape: `builds City{Beads: BeadsConfig{Provider, Backend, EventHooks, Policies}}, calls c.Marshal() then Parse(data), asserts each field round-trips` + - note: Add a conditional_writes assertion here (or a sibling test) to prove the string survives Marshal→Parse. Marshal is config.go:4195 (toml.NewEncoder, Indent=""); omitempty means empty conditional_writes is dropped, matching the '[beads] omitted when empty' expectation at config_test.go:669. +- **undecoded.go knownTOMLKeys reflection list — already includes BeadsConfig, so no registration needed for the new field** — `internal/config/undecoded.go:204` + - shape: `types := []reflect.Type{ ..., reflect.TypeOf(BeadsConfig{}) (line 204), reflect.TypeOf(DaemonConfig{}) (line 212), ... }` + - note: Because BeadsConfig is already reflected here, adding a toml-tagged field auto-registers it as a known key; no edit required. Confirms the unknown-key guard won't reject conditional_writes. +- **field_sync guard reference (for the DESIGN §3.2 reverse-parity Agent guard, not Beads)** — `internal/config/field_sync_test.go` + - shape: `TestAgentFieldSync (per AGENTS.md) enforces config.Agent ↔ AgentPatch/AgentOverride field parity` + - note: DESIGN §3.2/§425-426 wants a reflection walk failing the build if config.Agent/AgentPatch/AgentOverride gains a rollout.Mode field. That guard is a NEW test in internal/rollout's test package (may import both), not an edit to this file — but this file is the existing precedent for reflection-based field guards in config. + + GOTCHAS: + - IMPORT CYCLE: internal/rollout imports internal/config (Resolve takes *config.City), so BeadsConfig CANNOT have a method returning rollout.Mode. DESIGN line 507's `func (b BeadsConfig) ConditionalWritesMode() rollout.Mode` contradicts DESIGN line 293 and will not compile. The BeadsConfig accessor must return a plain string; the string→Mode mapping and Flags.BeadsConditionalWrites() Mode live in internal/rollout. This is load-bearing for the reverse-parity tests (DESIGN §425-426). + - The existing [beads] fragment-merge at compose.go:1030 is naive last-writer-wins (base.Beads = fragment.Beads). Without a preservation branch, a fragment that sets any [beads] key (e.g. bd_compatibility) silently erases a root-level conditional_writes. The task's 'IsDefined preservation branch' MUST rewrite this branch to mirror compose.go:1039-1045, and the regression test must prove cross-fragment survival. + - Default drift risk: the string→default mapping (empty ⇒ Off) in BeadsConfig's accessor must EQUAL the registry Spec.Default{Mode: ptr(Off)} (DESIGN line 441). DESIGN §521 requires a registry_test constructing a zero-value config.City and asserting the accessor == Spec.Default. Keep both in one place or the two-homes default can drift. + - omitempty on the new string field means empty conditional_writes is dropped from Marshal output — this is required so auto-generated city.toml never pins the default (matches config_test.go:669 which asserts Marshal drops an empty [beads]). Do NOT give it a non-empty zero value. + - jsonschema tag must be exactly `enum=off,enum=auto,enum=require` (comma-separated enum= entries, matching BDCompatibility's `enum=bd-1.0.4,enum=bd-1.0.5` at config.go:1377). DESIGN §425 forward-parity test asserts a Mode-kind Spec's field carries exactly this tag; any deviation (e.g. a single enum, a default=) fails the build. + - There is no field-sync test for BeadsConfig (unlike Agent). Adding conditional_writes needs no AgentPatch/AgentOverride mirroring — but per AGENTS.md, if a future Mode field were ever added to config.Agent it would need AgentPatch/AgentOverride/apply-func/pool deep-copy updates AND is forbidden by DESIGN §3.2. Keep the CAS flag on BeadsConfig only. + +## fragment-merge: the [beads] whole-table LWW branch and the daemon.formula_v2 per-field preservation idiom to replicate for conditional_writes + +**Idiom to follow:** Replicate the daemon.formula_v2 preservation branch (compose.go:1039-1045) for beads.conditional_writes. Three steps: (1) capture `conditionalWrites := base.Beads.ConditionalWrites` BEFORE the overwrite; (2) `base.Beads = fragment.Beads` (whole-table LWW preserved for deliberate overrides); (3) restore iff the fragment did not explicitly set the guarded key: `if !fragMeta.IsDefined(\"beads\", \"conditional_writes\") { base.Beads.ConditionalWrites = conditionalWrites }`. Single-key guard (unlike daemon's two-key guard for the graph_workflows alias). Field: add `ConditionalWrites string `toml:\"conditional_writes,omitempty\" jsonschema:\"enum=off,enum=auto,enum=require\"`` to BeadsConfig beside BDCompatibility (config.go:1377). Regression test: clone TestLoadWithIncludesPreservesExplicitFormulaV2FalseAcrossDaemonFragment (compose_test.go:82-107) — root sets conditional_writes=\"require\", fragment sets only a sibling [beads] key, assert require survives and the sibling applies. + +- **mergeFragment — merges a fragment City into the base City in-place. This is the function that must gain the beads.conditional_writes preservation branch.** — `internal/config/compose.go:980` + - shape: `func mergeFragment(base, fragment *City, fragMeta toml.MetaData, fragPath string, prov *Provenance)` + - note: fragMeta is the fragment's own toml.MetaData (produced by parseWithMeta at compose.go:397 and passed at the call site compose.go:423). IsDefined() is queried against the FRAGMENT's metadata, so it answers 'did THIS fragment set this key', not 'is it set anywhere'. +- **THE BUG SITE — [beads] whole-table last-writer-wins. A fragment that defines ANY [beads] key replaces the entire base.Beads struct with fragment.Beads, so the fragment's zero-value ConditionalWrites ("") silently erases the city's explicit opt-in. This is the require→off downgrade vector §4.2 calls out.** — `internal/config/compose.go:1030-1032` + - shape: `if fragMeta.IsDefined("beads") { + base.Beads = fragment.Beads + }` + - note: Sits in the 'Simple sections: last-writer-wins if fragment defines them' block (comment at compose.go:1029). dolt/formulas/session/mail/events/usage/orders/api/convergence/maintenance all use the identical unguarded whole-struct assignment — beads is the one that now needs the daemon-style guard. +- **THE IDIOM TO REPLICATE — daemon.formula_v2 (and graph_workflows alias) per-field preservation branch. Save the field before the whole-struct overwrite, restore it iff the fragment did NOT define the specific sub-key.** — `internal/config/compose.go:1039-1045` + - shape: `if fragMeta.IsDefined("daemon") { + formulaV2 := base.Daemon.FormulaV2 + base.Daemon = fragment.Daemon + if !fragMeta.IsDefined("daemon", "formula_v2") && !fragMeta.IsDefined("daemon", "graph_workflows") { + base.Daemon.FormulaV2 = formulaV2 + } + }` + - note: Three-step shape: (1) capture base field into a local; (2) whole-struct overwrite base.X = fragment.X; (3) if the fragment did not explicitly define the guarded sub-key, restore the captured value. Daemon checks TWO keys because FormulaV2 has a deprecated alias (graph_workflows); beads.conditional_writes has ONE key, so the guard is a single !fragMeta.IsDefined("beads","conditional_writes"). A fragment that DOES set conditional_writes still wins (deliberate override preserved). +- **EXACT branch to add for conditional_writes (per DESIGN.md §4.2, lines 553-562). Replaces the compose.go:1030-1032 block.** — `internal/config/compose.go:1030 (replacement target)` + - shape: `if fragMeta.IsDefined("beads") { + conditionalWrites := base.Beads.ConditionalWrites + base.Beads = fragment.Beads + if !fragMeta.IsDefined("beads", "conditional_writes") { + base.Beads.ConditionalWrites = conditionalWrites + } + }` + - note: Mirror the daemon idiom exactly. Only the field-preserving read/write differs. Note base.Beads (not base.Daemon) and the single-key guard. bd_compatibility / provider / backend / event_hooks / policies are NOT preserved individually — they intentionally keep whole-table LWW; only the rollout flag needs the guard because its zero value is a silent correctness downgrade. +- **BeadsConfig struct — where the new ConditionalWrites field goes, directly beside its precedent BDCompatibility (bd_compatibility).** — `internal/config/config.go:1356-1382` + - shape: `type BeadsConfig struct { + Provider string `toml:"provider,omitempty"` + Backend string `toml:"backend,omitempty"` + EventHooks *bool `toml:"event_hooks,omitempty"` + BDCompatibility string `toml:"bd_compatibility,omitempty" jsonschema:"enum=bd-1.0.4,enum=bd-1.0.5"` + Policies map[string]BeadPolicyConfig `toml:"policies,omitempty"` + }` + - note: DESIGN §4.1 (lines 483-491) specifies: ConditionalWrites string `toml:"conditional_writes,omitempty" jsonschema:"enum=off,enum=auto,enum=require"`. bd_compatibility (config.go:1377) is the exact structural precedent — a validated string enum owning a bd-semantics opt-in. Import direction is load-bearing (DESIGN line 293): config must NOT import rollout, so the field is a plain validated string; the string→rollout.Mode mapping lives in a BeadsConfig.ConditionalWritesMode() helper (mirror NormalizedBDCompatibility at config.go:1401-1410). +- **NormalizedBDCompatibility — the string-normalizer helper method to mirror for ConditionalWritesMode(). Empty ⇒ default; switch over known constants.** — `internal/config/config.go:1398-1410` + - shape: `func (b BeadsConfig) NormalizedBDCompatibility() string { + switch b.BDCompatibility { + case "", BeadsBDCompatibility104: + return BeadsBDCompatibility104 + case BeadsBDCompatibility105: + return BeadsBDCompatibility105 + default: + return BeadsBDCompatibility104 + } + }` + - note: DESIGN §4.1 (lines 504-512) wants: func (b BeadsConfig) ConditionalWritesMode() rollout.Mode { if b.ConditionalWrites == "" { return }; return rollout.Mode(b.ConditionalWrites) }. NOTE the import-direction asymmetry: this helper CAN return rollout.Mode only if it lives in internal/rollout (which imports config), NOT on BeadsConfig in internal/config. DESIGN line 55 lists Flags.BeadsConditionalWrites() Mode as the typed read surface living in internal/rollout. Confirm placement against §4.1 before coding — config.go must stay rollout-import-free. +- **DaemonConfig.FormulaV2 field — the *bool the daemon preservation branch protects. Reference for why zero-value overwrite is dangerous.** — `internal/config/config.go:2294-2297` + - shape: `FormulaV2 *bool `toml:"formula_v2,omitempty" jsonschema:"default=true"` + GraphWorkflows bool `toml:"graph_workflows,omitempty"`` + - note: FormulaV2 is a *bool (nil = default-on). Read via DaemonConfig.FormulaV2Enabled() (config.go:4277-4279), never the raw pointer. conditional_writes uses a string, not *bool — its 'absent' sentinel is "" (⇒ Off default), which is exactly why the whole-table overwrite is a silent downgrade a struct-comparison cannot detect (DESIGN line 566: IsDefined distinguishes 'key present' from 'zero value'). +- **applyDaemonFormulaV2Default — post-decode default normalizer (formula_v2/graph_workflows precedence). Precedent for any post-load default handling if conditional_writes needs one.** — `internal/config/config.go:4281-4298` + - shape: `func applyDaemonFormulaV2Default(cfg *City, md toml.MetaData) { ... md.IsDefined("daemon","formula_v2") ... md.IsDefined("daemon","graph_workflows") ... }` + - note: Called at config.go:4258 and compose.go:1481 (both composition-adjacent paths). This is a ROOT-level default pass keyed off the final merged MetaData, distinct from the per-fragment mergeFragment preservation. conditional_writes default (off) is handled by the empty-string branch in ConditionalWritesMode(), so a parallel applyDaemon...Default is likely NOT needed — but the two IsDefined call sites (4258, 1481) show where a root normalizer would hook if required. +- **THE REGRESSION-TEST IDIOM TO MIRROR — TestLoadWithIncludesPreservesExplicitFormulaV2FalseAcrossDaemonFragment. Root sets the guarded key; a fragment defines a DIFFERENT sibling key in the same section; assert the root value survives AND the fragment's sibling applies.** — `internal/config/compose_test.go:82-107` + - shape: `func TestLoadWithIncludesPreservesExplicitFormulaV2FalseAcrossDaemonFragment(t *testing.T) { + fs := fsys.NewFake() + fs.Files["/city/city.toml"] = []byte(`include=["fragment.toml"]\n[workspace]\nname="test"\n[daemon]\nformula_v2 = false`) + fs.Files["/city/fragment.toml"] = []byte(`[daemon]\npatrol_interval = "1m"`) + cfg, _, err := LoadWithIncludes(fs, "/city/city.toml") + // assert cfg.Daemon.FormulaV2Enabled()==false AND cfg.Daemon.PatrolInterval=="1m" + }` + - note: Copy this verbatim for conditional_writes: root city.toml sets [beads] conditional_writes="require"; fragment.toml sets [beads] with only a sibling (e.g. provider="bd" or bd_compatibility="bd-1.0.5"); assert cfg.Beads.ConditionalWrites=="require" (survives) AND the fragment sibling applied. Uses fsys.NewFake() + LoadWithIncludes(fs, path). Sibling test file at compose_test.go:49-62 (TestLoadWithIncludesDefaultsFormulaV2Enabled) shows the absent-key default case; 64-80 shows explicit-false-no-fragment. Build the mirrored trio for conditional_writes. +- **parseWithMeta — produces the per-fragment toml.MetaData consumed by mergeFragment; establishes that IsDefined is fragment-scoped.** — `internal/config/compose.go:1471 (def), compose.go:397 (call)` + - shape: `func parseWithMeta(data []byte, source string) (*City, toml.MetaData, []string, error)` + - note: toml is github.com/BurntSushi/toml (import at compose.go:10 and config.go:15). MetaData.IsDefined(keys ...string) bool is BurntSushi's API — variadic dotted-path membership over what THIS document decoded. That fragment-scoping is the whole reason the preservation branch is correct: base gets overwritten by fragment.Beads (zero ConditionalWrites), then restored because the fragment's MetaData does not contain the beads.conditional_writes path. +- **Fragment merge call site — the loop where each fragment is parsed then merged into root, showing fragMeta flows straight from parse to mergeFragment.** — `internal/config/compose.go:397-425` + - shape: `frag, fragMeta, fragWarnings, err := parseWithMeta(fragData, fragPath) + ... adjustAgentPaths/adjustPatchPaths/adjustRigOverridePaths ... + mergeFragment(root, frag, fragMeta, fragPath, prov)` + - note: Confirms fragMeta is the un-merged, per-file metadata (not cumulative), so the preservation guard reasons about a single fragment at a time. No bd_compatibility-specific preservation exists today — bd_compatibility rides the whole-table LWW at compose.go:1031 and is intentionally NOT individually preserved; only conditional_writes needs the guard. + + GOTCHAS: + - IsDefined is FRAGMENT-scoped, not cumulative: fragMeta comes from parseWithMeta on a SINGLE fragment file (compose.go:397). The guard reasons about 'did this one fragment set beads.conditional_writes', which is exactly right. + - Do NOT preserve bd_compatibility/provider/backend/event_hooks individually — they intentionally keep whole-table LWW. Only the rollout flag (conditional_writes) needs the guard because its zero value "" is a silent correctness downgrade (require→off), and a struct comparison cannot distinguish 'fragment set it to off' from 'fragment left it zero' — only IsDefined can (DESIGN line 566). + - Daemon guards TWO keys (formula_v2 AND the deprecated graph_workflows alias). conditional_writes has ONE key — do not copy the double-key check; a single !fragMeta.IsDefined("beads","conditional_writes") is correct. + - Import direction is load-bearing: internal/config must NOT import internal/rollout (rollout imports config for Resolve(*config.City)). So the BeadsConfig field is a plain validated string; the string→rollout.Mode mapping and typed accessor (Flags.BeadsConditionalWrites() Mode) live in internal/rollout, NOT as a method returning rollout.Mode on BeadsConfig in config.go. DESIGN §4.1's ConditionalWritesMode()->rollout.Mode helper must be placed in the rollout package (or config exposes only the raw string + a config-local normalizer). Verify placement against DESIGN lines 293 and 504-512 before coding. + - A deliberate fragment override must still win: a fragment that explicitly sets conditional_writes="auto" over a pack's "off" is legitimate LWW and the guard correctly allows it (the !IsDefined check is false, so no restore). + - mergeFragment mutates base IN-PLACE (comment at compose.go:978). The capture-before-overwrite ordering is mandatory — reading base.Beads.ConditionalWrites after `base.Beads = fragment.Beads` would read the fragment's zero value. + - There are TWO composition roots that will need Resolve wiring per the task, but only ONE mergeFragment; the preservation branch lives solely in mergeFragment. applyDaemonFormulaV2Default is called from BOTH compose.go:1481 and config.go:4258 — if conditional_writes ever needs a root-level default normalizer, those are the two hook points, but the empty-string default in ConditionalWritesMode() likely makes it unnecessary. + +## composition-roots (feature-flag / rollout gate application points in cmd/gc and internal/api) + +**Idiom to follow:** Both composition roots today take a `*config.City` and PUSH a derived bool into two package-level `atomic.Bool` globals via `Set*` setters. That is the anti-pattern the design forbids. The rollout replacement: at each root call `flags, notices := rollout.Resolve(cfg, opts)` ONCE and thread the immutable `flags` value by DI to consumers (compile/fragment/molecule/dispatch) instead of stamping a global. Reuse the exact per-field fragment-merge PRESERVATION idiom already proven for `[daemon] formula_v2` (compose.go:1039-1044) when adding `[beads] conditional_writes`. Reuse the pointer-field + `XxxEnabled()` accessor idiom (BeadsConfig.EventHooks / DaemonConfig.FormulaV2) for the new typed config field, but return `rollout.Mode` (off/auto/require) not a bool. + +- **Composition root #1: cmd/gc applyFeatureFlags — derives one bool from config and stamps BOTH global setters. This is the primary thread point for rollout.Resolve in cmd/gc.** — `cmd/gc/feature_flags.go:12` + - shape: `func applyFeatureFlags(cfg *config.City) { gw := cfg.Daemon.FormulaV2Enabled(); formula.SetFormulaV2Enabled(gw); molecule.SetGraphApplyEnabled(gw) }` + - note: Whole body is 3 lines (12-16). imports config, formula, molecule. Replace/augment with `flags,_ := rollout.Resolve(cfg, opts)` and stop mutating globals. NOTE it stores UNCONDITIONALLY (no compare), unlike syncFeatureFlags. +- **applyFeatureFlags non-test call sites — 7 production sites (8 counting cmd_agent's two loaders). Each sits immediately AFTER a config load and BEFORE any formula compile / molecule instantiation. These are where a resolved Flags value must be produced and handed downstream.** — `cmd/gc/cmd_start.go:673, cmd/gc/doctor_provider_catalog.go:146, cmd/gc/api_state.go:1808, cmd/gc/controller.go:923, cmd/gc/cmd_agent.go:52, cmd/gc/cmd_agent.go:70, cmd/gc/cmd_sling.go:247` + - shape: `pattern: cfg,prov,err := load...(); ...; applyFeatureFlags(cfg)` + - note: api_state.go:1808 and controller.go:923 are the RELOAD paths (loadCurrentConfigSnapshot / reload) — they re-call applyFeatureFlags on every reload, re-mutating globals mid-run. Design REQUIRES process-latched mode: the beads flag must NOT be re-resolved on reload; carry the boot snapshot. cmd_agent.go:52/70 are inside shared loaders loadCityConfigFS / loadCityConfigWithoutBuiltinPackRefreshFS. +- **Shared loader helpers that call applyFeatureFlags — the design's 'once, in the shared config loaders' home. loadCityConfigFS is the widest funnel (wraps config.LoadWithIncludes).** — `cmd/gc/cmd_agent.go:39 (loadCityConfigFS), cmd/gc/cmd_agent.go:61 (loadCityConfigWithoutBuiltinPackRefreshFS), cmd/gc/cmd_start.go:989 (loadStartCityConfig), cmd/gc/cmd_sling.go:433 (loadSlingCityConfig), cmd/gc/cmd_config.go:25 (loadCityConfigWithBuiltinPacks)` + - shape: `func loadCityConfigFS(fs fsys.FS, tomlPath string, warningWriter ...io.Writer) (*config.City, error)` + - note: loadCityConfigFS:43 calls config.LoadWithIncludes then applyFeatureFlags at :52. Threading Resolve here (returning Flags alongside cfg) centralizes it, but stage-1 minimal thread is at the two existing root functions. +- **Composition root #2: internal/api syncFeatureFlags — the API-server equivalent of applyFeatureFlags. Same two globals, but stores CONDITIONALLY (compare-before-store).** — `internal/api/server.go:229` + - shape: `func syncFeatureFlags(cfg *config.City) { enabled := cfg != nil && cfg.Daemon.FormulaV2Enabled(); if formula.IsFormulaV2Enabled() != enabled { formula.SetFormulaV2Enabled(enabled) }; if molecule.IsGraphApplyEnabled() != enabled { molecule.SetGraphApplyEnabled(enabled) } }` + - note: Called from New (server.go:197) and NewReadOnly (server.go:203) so both server modes observe identical flag state. The nil-guard (`cfg != nil`) and compare-before-store asymmetry vs applyFeatureFlags is a real smell the unified Resolve should erase. +- **syncFeatureFlags call sites — the API server constructors.** — `internal/api/server.go:197 (New), internal/api/server.go:203 (NewReadOnly)` + - shape: `func New(state State) *Server { syncFeatureFlags(state.Config()); return newServer(state,false) }` + - note: state.Config() returns *config.City. This is the second (and only other) production place rollout.Resolve must be threaded. +- **GLOBAL MUTABLE ANTI-PATTERN #1 — must NOT copy. Package-level atomic.Bool for formula_v2, defaulted TRUE via init(), with Set/Is accessors mutated from both roots.** — `internal/formula/compile.go:623 (var), :626 (init sets true), :632 (SetFormulaV2Enabled), :638 (IsFormulaV2Enabled)` + - shape: `var formulaV2Enabled atomic.Bool; func init(){ formulaV2Enabled.Store(true) }; func SetFormulaV2Enabled(v bool); func IsFormulaV2Enabled() bool` + - note: The init()-to-true means the process behaves formula_v2=ON before any Resolve runs — rollout must express Spec.Default without an init() side effect. Read-site inside package at compile.go:55 (v2Enabled snapshot per compile) and fragment.go:27. +- **GLOBAL MUTABLE ANTI-PATTERN #2 — must NOT copy. Second package-level atomic.Bool for graph-apply, driven by the SAME source bool.** — `internal/molecule/graph_apply.go:25 (var), :30 (SetGraphApplyEnabled), :36 (IsGraphApplyEnabled)` + - shape: `var graphApplyEnabled atomic.Bool; func SetGraphApplyEnabled(v bool); func IsGraphApplyEnabled() bool` + - note: Two globals derived from one config bool (cfg.Daemon.FormulaV2Enabled()). Read-sites: molecule.go:475, molecule.go:784, dispatch/ralph.go:416. These become DI-threaded Flags reads under rollout. +- **Current TEST seam propping up the global — the exact save/restore + process-mutex pattern rollout.ForTest replaces with a per-instance immutable value.** — `internal/formulatest/v2.go:12 (var v2Mu sync.Mutex), :17 (LockV2ForTest), :39 (SetV2ForTest), :46 (EnableV2ForTest)` + - shape: `func LockV2ForTest(tb testing.TB) func(enabled bool) { v2Mu.Lock(); prev:=formula.IsFormulaV2Enabled(); tb.Cleanup(...restore...); return func(enabled bool){ formula.SetFormulaV2Enabled(enabled) } }` + - note: Process-global mutex + prev/restore is precisely the t.Parallel-unsafe pattern the design bans. rollout.ForTest(t, WithBeadsConditionalWrites(Require)) must build state PER INSTANCE (no global, no mutex, compile-time-checked options). +- **Fragment-merge PRESERVATION branch to MIRROR — the [daemon] formula_v2 whole-section-replacement footgun fix. The [beads] section is CURRENTLY whole-section last-writer-wins (resets siblings).** — `internal/config/compose.go:1030-1032 ([beads] naive replace), :1039-1044 ([daemon] preservation template)` + - shape: `// daemon template: formulaV2 := base.Daemon.FormulaV2; base.Daemon = fragment.Daemon; if !fragMeta.IsDefined("daemon","formula_v2") && !fragMeta.IsDefined("daemon","graph_workflows") { base.Daemon.FormulaV2 = formulaV2 }` + - note: CRITICAL: compose.go:1030 currently does `if fragMeta.IsDefined("beads"){ base.Beads = fragment.Beads }`. Adding conditional_writes without a preservation branch means a fragment defining ONLY bd_compatibility silently zeroes conditional_writes. New branch must save base.Beads.ConditionalWrites, assign fragment.Beads, restore unless fragMeta.IsDefined("beads","conditional_writes"). This is the merge regression test the task calls for. +- **Config field + accessor idiom to follow for the new [beads] conditional_writes typed field.** — `internal/config/config.go:1356 (BeadsConfig struct), :1377 (BDCompatibility sibling field), :1386 (EventHooksEnabled accessor); daemon precedent config.go:2294 (FormulaV2 *bool), :4277 (FormulaV2Enabled())` + - shape: `type BeadsConfig struct { ... BDCompatibility string `toml:"bd_compatibility,omitempty" jsonschema:"enum=..."` ... }; func (b BeadsConfig) EventHooksEnabled() bool` + - note: conditional_writes lands here beside bd_compatibility (domain-local, inherits [beads] IsDefined merge). Use a string field with jsonschema enum=off,auto,require; the typed Mode accessor lives in internal/rollout (Resolve reads this field), NOT a raw accessor on BeadsConfig, to keep the package beads-import-free per design. +- **Proof there is NO third production composition root — only the two roots + one test helper touch the globals. Grounds the freeze/golden-list test.** — `grep of SetFormulaV2Enabled/SetGraphApplyEnabled callers (non-test, this repo): cmd/gc/feature_flags.go:14-15, internal/api/server.go:232,235, internal/formulatest/v2.go:23,26` + - shape: `only 2 production call sites (both roots) + 1 test seam` + - note: Freeze test golden-list must pin exactly: applyFeatureFlags, syncFeatureFlags, SetFormulaV2Enabled, SetGraphApplyEnabled callers. (Other matches under worktrees/ and .worktrees/ are stale checkout copies — ignore.) + + GOTCHAS: + - Two globals, one source: both applyFeatureFlags and syncFeatureFlags derive TWO atomic.Bool globals (formula.formulaV2Enabled + molecule.graphApplyEnabled) from ONE config bool cfg.Daemon.FormulaV2Enabled(). Any rollout replacement must keep that fan-out but sourced from an immutable Flags value, not re-mutated globals. + - Asymmetric write semantics: applyFeatureFlags (cmd/gc) stores UNCONDITIONALLY; syncFeatureFlags (api) compares-before-store (`if Is()!=enabled`). A unified Resolve should make idempotency structural, not per-root discipline. + - init()-to-true default: internal/formula/compile.go:626 sets formulaV2Enabled=true at package init, so the process is formula_v2=ON before any config load/Resolve. rollout.Resolve must reproduce Spec.Default deterministically WITHOUT an init() side effect (the design's zero-package-mutable-state gate forbids the init/global entirely). + - Reload re-mutates mid-run: controller.go:923 and api_state.go:1808 re-invoke applyFeatureFlags on every config reload. For the beads CAS flag the design mandates PROCESS-LATCHED mode (a legacy writer racing a CAS writer inside one process is the exact corruption gated) — Resolve for beads must carry the boot snapshot and surface divergence as a 'pending restart' notice, NOT re-apply. + - [beads] fragment merge is currently whole-section last-writer-wins (compose.go:1030-1032). Without mirroring the [daemon] per-field preservation branch (1039-1044), a fragment that sets only bd_compatibility will silently reset conditional_writes to its zero value — the merge regression test must assert this cannot happen. + - The formulatest/v2.go process-mutex (v2Mu) exists ONLY because the flag is a mutable global; do not port it. rollout.ForTest must return a per-instance immutable value so t.Parallel is safe by construction — copying the mutex reintroduces the exact banned pattern. + - Downstream consumers read the globals directly (formula/compile.go:55, formula/fragment.go:27, molecule/molecule.go:475 & :784, dispatch/ralph.go:416). 'Threading via DI' means these read-sites must accept the resolved Flags value; stage 1 wires Resolve at the two roots but the full DI conversion of these five readers is what makes deleting a flag a COMPILE error. + +## legacy-feature-flags: the daemon.formula_v2 global-setter anti-pattern (cmd/gc/feature_flags.go + formula/molecule atomic.Bool pair + formulatest.LockV2ForTest process mutex + ~44 test save/restore blocks) — what stage 1 FREEZES (golden-list boundary test + per-package ceiling) and stage 5 DELETES + +**Idiom to follow:** Mirror TestGCNonTestFilesStayOnWorkerBoundary (cmd/gc/worker_boundary_import_test.go:11) EXACTLY for the golden-list freeze: runtime.Caller(0) → filepath.Dir → os.ReadDir(dir), skip dirs + non-.go + _test.go, os.ReadFile, strings.Contains(content, needle), t.Fatalf naming the offending file+needle. For the freeze the loop must instead COUNT occurrences per file and compare to a checked-in shrink-only inventory map[string]int (grow=fail, shrink=requires baseline edit). Golden-list walks NON-TEST source only (same as worker boundary). The four frozen symbols are string needles: `applyFeatureFlags`, `syncFeatureFlags`, `formula.SetFormulaV2Enabled`, `molecule.SetGraphApplyEnabled`. Note the current cmd/gc file is package-local (walks only cmd/gc dir via runtime.Caller); to cover internal/api/server.go the freeze test needs a repo-root walk or a second inventory. The global-setter idiom being frozen out is: package-var `atomic.Bool` + `init(){Store(true)}` + `SetX(v)`/`IsX()` pair (formula/compile.go:623-640, molecule/graph_apply.go:25-38); the replacement idiom is a `rollout.Flags` VALUE handed to constructors (no package state, t.Parallel-safe), and for the formula read-path the existing explicit-parameter shape `ValidateHostRequirements(f *Formula, formulaV2Enabled bool)` (formula/requirements.go:76). + +- **applyFeatureFlags — the cmd/gc bridge that pushes daemon config into the two package globals; FROZEN symbol #1, deleted in stage 5** — `cmd/gc/feature_flags.go:12` + - shape: `func applyFeatureFlags(cfg *config.City) { gw := cfg.Daemon.FormulaV2Enabled(); formula.SetFormulaV2Enabled(gw); molecule.SetGraphApplyEnabled(gw) } — whole file is 17 lines, imports config, formula, molecule` + - note: Golden-list baseline counts this file at 1 (the definition on :12; :9 is a doc-comment mention not counted). Entire file is deleted in stage 5. +- **applyFeatureFlags NON-TEST call sites — the 7 call sites in cmd/gc that the golden list freezes at their current counts** — `cmd/gc/cmd_agent.go:52, cmd/gc/cmd_agent.go:70, cmd/gc/cmd_start.go:673, cmd/gc/controller.go:923, cmd/gc/cmd_sling.go:247, cmd/gc/api_state.go:1808, cmd/gc/doctor_provider_catalog.go:146` + - shape: `each call is `applyFeatureFlags(cfg)` (or nextCfg/newCfg on reload paths). cmd_agent.go:52 is inside loadCityConfigWithBuiltinPacks (calls config.LoadWithIncludes then applyFeatureFlags at :52); cmd_agent.go:70 inside loadCityConfigWithoutBuiltinPackRefreshFS — these two are the composition roots where stage 1 folds in rollout.Resolve` + - note: Proposed inventory (DESIGN 13.1.1, DESIGN.md:2076-2085): cmd_agent.go=2, cmd_start.go=1, controller.go=1, cmd_sling.go=1, api_state.go=1, doctor_provider_catalog.go=1, feature_flags.go=1. api_state.go:1808 is the reload path. +- **syncFeatureFlags — the internal/api twin bridge; FROZEN symbol #2, deleted in stage 5** — `internal/api/server.go:229 (def), called at server.go:197 (New) and server.go:203 (NewReadOnly)` + - shape: `func syncFeatureFlags(cfg *config.City) { enabled := cfg != nil && cfg.Daemon.FormulaV2Enabled(); if formula.IsFormulaV2Enabled() != enabled { formula.SetFormulaV2Enabled(enabled) }; if molecule.IsGraphApplyEnabled() != enabled { molecule.SetGraphApplyEnabled(enabled) } }` + - note: Golden-list baseline: internal/api/server.go=3 (def :229 + calls :197, :203). Replaced in stage 5 by a server options struct carrying rollout.Flags (server never re-resolves). This is OUTSIDE cmd/gc so the freeze test's dir-walk must be widened or given a second inventory. +- **formula.SetFormulaV2Enabled / IsFormulaV2Enabled + the atomic.Bool — half of the atomic.Bool pair; FROZEN symbol #3, deleted stage 5** — `internal/formula/compile.go:623 (var), :625 init, :632 SetFormulaV2Enabled, :638 IsFormulaV2Enabled` + - shape: `var formulaV2Enabled atomic.Bool; func init(){ formulaV2Enabled.Store(true) }; func SetFormulaV2Enabled(v bool){ formulaV2Enabled.Store(v) }; func IsFormulaV2Enabled() bool { return formulaV2Enabled.Load() }` + - note: Doc-comment (compile.go:616-622) explains it's stored atomic so reload races safely; each compile snapshots once. Production read-sites (re-threaded via explicit param in stage 5): compile.go:55 (v2Enabled := IsFormulaV2Enabled()), fragment.go:27, requirements.go path via ValidateHostRequirements(f, formulaV2Enabled bool) at requirements.go:76. +- **molecule.SetGraphApplyEnabled / IsGraphApplyEnabled + the atomic.Bool — other half of the pair; FROZEN symbol #4, deleted stage 5** — `internal/molecule/graph_apply.go:25 (var graphApplyEnabled atomic.Bool), :30 SetGraphApplyEnabled, :36 IsGraphApplyEnabled` + - shape: `var graphApplyEnabled atomic.Bool; func SetGraphApplyEnabled(v bool){ graphApplyEnabled.Store(v) }; func IsGraphApplyEnabled() bool { return graphApplyEnabled.Load() }` + - note: NOTE: graph_apply.go has NO init() — graphApplyEnabled defaults to false (unlike formula which inits true); it is only ever true after applyFeatureFlags/syncFeatureFlags runs. Production read-sites re-threaded in stage 5: molecule.go:475, molecule.go:784, dispatch/ralph.go:416. Replaced by a field on molecule/Instantiate Options struct. +- **formulatest.LockV2ForTest — the process-wide test mutex that serializes the global-setter tests; deleted in stage 5 (nothing replaces it — per-instance values need no mutex)** — `internal/formulatest/v2.go:12 (var v2Mu sync.Mutex), :17 LockV2ForTest, :32 HoldV2ForTest, :39 SetV2ForTest, :46 EnableV2ForTest` + - shape: `var v2Mu sync.Mutex; func LockV2ForTest(tb testing.TB) func(enabled bool) { v2Mu.Lock(); prev := formula.IsFormulaV2Enabled(); tb.Cleanup(func(){ defer v2Mu.Unlock(); formula.SetFormulaV2Enabled(prev) }); return func(enabled bool){ formula.SetFormulaV2Enabled(enabled) } } — non-reentrant, call once per test goroutine. HoldV2/SetV2/EnableV2ForTest are thin wrappers over it.` + - note: Whole file (50 lines) is deleted in stage 5. Usage: cmd/gc(5), internal/dispatch(2), internal/api(2), internal/sling(1), internal/molecule(1), internal/graphv2(1). DESIGN calls this and the atomic.Bool pair 'the anti-pattern this deletes' (DESIGN.md:842, :949). +- **The ~44 in-package save/restore blocks — the pattern the per-package count ceiling freezes (test files are NOT enumerated; DESIGN says listing them 'buys nothing')** — `internal/formula (54 refs across compile_test.go, requirements_test.go, testhelper_test.go, fragment_test.go, graphv2_validation_test.go), internal/molecule (44: molecule_test.go=42 + attach_test.go=2), internal/dispatch(8), cmd/gc(5), internal/graphroute(4), internal/api(4), internal/bootstrap(2)` + - shape: `idiomatic block: prev := IsGraphApplyEnabled(); SetGraphApplyEnabled(true); t.Cleanup(func(){ SetGraphApplyEnabled(prev) }). Formula-package convenience helper: internal/formula/testhelper_test.go:5 enableV2ForTest(tb) does the prev/Set/Cleanup dance.` + - note: DESIGN.md:2090 freezes TEST files by per-package count ceiling (not file inventory): a new SetFormulaV2Enabled in a package at ceiling fails with 'use rollout.ForTest(t, rollout.WithFormulaV2(...)) instead'. Replacement is rollout.ForTest(t, rollout.WithFormulaV2(false)) — compile-time-typed, t.Parallel-safe. +- **config anchor NOT deleted in stage 5 — the daemon.formula_v2 field, accessor, default-application, and the deprecated graph_workflows alias; this is the flag's permanent config home** — `internal/config/config.go:2294 (FormulaV2 *bool field), :2297 (GraphWorkflows bool alias), :4277 (FormulaV2Enabled method), :4281 (applyDaemonFormulaV2Default), compose.go:1481 & config.go:4258 (default-apply call sites)` + - shape: `FormulaV2 *bool `toml:"formula_v2,omitempty" jsonschema:"default=true"`; GraphWorkflows bool `toml:"graph_workflows,omitempty"`; func (d DaemonConfig) FormulaV2Enabled() bool { return d.FormulaV2 == nil || *d.FormulaV2 }; func applyDaemonFormulaV2Default(cfg *City, md toml.MetaData) — explicit formula_v2 wins, else graph_workflows alias, else leave nil (default-on, omitted from generated config)` + - note: nil pointer = default-ON and OMITTED from generated configs; explicit false preserved as non-nil false. This is the template for the NEW [beads] conditional_writes field. Stays until the flag itself graduates to deletion (then gets its own tombstone). +- **The fragment-merge preservation branch to MIRROR for [beads] conditional_writes — the daemon special-case that survives last-writer-wins** — `internal/config/compose.go:1039-1045 (inside mergeFragment)` + - shape: `if fragMeta.IsDefined("daemon") { formulaV2 := base.Daemon.FormulaV2; base.Daemon = fragment.Daemon; if !fragMeta.IsDefined("daemon","formula_v2") && !fragMeta.IsDefined("daemon","graph_workflows") { base.Daemon.FormulaV2 = formulaV2 } }` + - note: Contrast with the plain last-writer-wins simple sections just above: `if fragMeta.IsDefined("beads") { base.Beads = fragment.Beads }` at compose.go:1030-1032 — a fragment defining [beads] TODAY blows away the whole struct. Stage 1 must add the IsDefined("beads","conditional_writes") preservation branch here exactly as daemon.formula_v2 does, plus a hand-written merge regression test. +- **graph_workflows deprecated-alias tombstone obligation — live today, retirement registered stage 1, executed stage 5** — `config.go:2297 (field), config.go:4291-4294 (honored only when formula_v2 absent), compose.go:1042 (its clause in the merge special-case)` + - shape: `GraphWorkflows bool `toml:"graph_workflows,omitempty"`; in applyDaemonFormulaV2Default: if md.IsDefined("daemon","graph_workflows"){ v := cfg.Daemon.GraphWorkflows; cfg.Daemon.FormulaV2 = &v; return }` + - note: DESIGN.md:2167 — stage 1 registers the retirement obligation as a bead linked from the formula_v2 Owner bead; stage 5 executes it with a RemovedIn version anchor. + + GOTCHAS: + - The current freeze template TestGCNonTestFilesStayOnWorkerBoundary (cmd/gc/worker_boundary_import_test.go) walks ONLY the cmd/gc directory (runtime.Caller(0)→filepath.Dir). But two of the four frozen symbols live OUTSIDE cmd/gc: formula.SetFormulaV2Enabled (internal/formula/compile.go) and molecule.SetGraphApplyEnabled (internal/molecule/graph_apply.go), and syncFeatureFlags is in internal/api/server.go. A byte-identical mirror will silently NOT cover them. The golden-list freeze needs a repo-root walk (or a checked-in multi-file inventory keyed by repo-relative path, which is what DESIGN.md:2076 actually specifies) — not a package-local dir walk. + - molecule/graph_apply.go has NO init() so graphApplyEnabled defaults to FALSE, whereas formula/compile.go:625 init()s formulaV2Enabled to TRUE. The two 'atomic.Bool pair' halves have opposite defaults; they only agree because applyFeatureFlags/syncFeatureFlags always set both from the same cfg.Daemon.FormulaV2Enabled(). A per-instance rollout.Flags replacement must preserve the effective default-ON semantics (nil pointer → enabled) for BOTH, not copy graph_apply's false zero-value. + - The DESIGN's proposed golden-list inventory (DESIGN.md:2077-2085) counts feature_flags.go at 1 = the applyFeatureFlags DEFINITION on :12. feature_flags.go:9 is a doc-comment mention of the name — a naive strings.Count would tally 2. Decide whether comments count and be consistent, or the baseline will be off-by-one per file that documents the symbol. + - '~20 molecule_test save/restores' in the task/DESIGN prose is an undercount: internal/molecule actually has 44 setter references (molecule_test.go=42 Set-lines + attach_test.go=2), and the whole fleet has ~121 setter references across 7 packages (formula 54, molecule 44, dispatch 8, cmd/gc 5, graphroute 4, api 4, bootstrap 2). DESIGN.md:2090 correctly says freeze TEST files by per-package COUNT CEILING, not by file enumeration — do not try to inventory individual test lines. + - formulatest exposes FOUR helpers over the one mutex (LockV2ForTest, HoldV2ForTest, SetV2ForTest, EnableV2ForTest at v2.go:17/32/39/46) plus formula/testhelper_test.go:5 enableV2ForTest — a stage-5 grep for just 'LockV2ForTest' misses the wrapper call sites. Per No-Semantic-Search: grep all four names. + - syncFeatureFlags and applyFeatureFlags are DUPLICATE bridges doing the same job in two packages (internal/api vs cmd/gc) — this is the 'seven call sites vs one consumer' recruiting hazard the freeze targets (DESIGN.md:2066). Both must be frozen in stage 1 and both deleted in stage 5; freezing only the cmd/gc one leaves the api twin free to grow. + +## adhoc-env-flags — the three ad-hoc env gates (GC_DOLT_AUTO_GC_ENABLED, GC_EVENTS_ROTATION_ENABLED, GC_ALLOW_PROD_DOLT_PORT_IN_TESTS) internal/rollout will absorb. All paths are in the reconciler worktree /data/projects/gascity/.claude/worktrees/reconciler. Each gate has a DIVERGENT truthy parser and a DIFFERENT precedence model, and NONE of the three is in testenv.LeakVectorVars. + +**Idiom to follow:** Two distinct patterns exist for boolean env gates; pick deliberately per-Spec when absorbing. (1) env-FILLS-NIL (config wins): read env only when the *bool config field is nil — see resolveManagedDoltConfigForStart at cmd/gc/dolt_start_managed.go:972-975 `if doltConfig.AutoGCEnabled == nil { if parsed, ok := parseEnvAutoGCEnabled(...); ok { doltConfig.AutoGCEnabled = &parsed } }`. (2) env-OVERRIDES (env wins): seed a resolved bool from config's *OrDefault() then let a present+valid env value overwrite it — see providers.go:990-1005 (`settings.enabled = rot.EnabledOrDefault()` then `if raw, ok := os.LookupEnv(...); ok { if parsed, parseOK := parse(raw); parseOK { settings.enabled = parsed } else { warnEventsRotation(...) } }`). The rollout resolver's Mode(Off/Auto/Require) should make this precedence choice explicit rather than reproducing two hand-rolled idioms. The prod-port guard is a THIRD shape: a test-only bypass on exact `== "1"`, not a config resolver at all. + +- **GC_DOLT_AUTO_GC_ENABLED read site (env-fills-nil precedence)** — `cmd/gc/dolt_start_managed.go:972-975` + - shape: `inside func resolveManagedDoltConfigForStart(cityPath string, explicitArchiveLevel int) (config.DoltConfig, error) {resolveManagedDoltConfigForStart defined at :947}. Body: `if doltConfig.AutoGCEnabled == nil { if parsed, ok := parseEnvAutoGCEnabled(os.Getenv("GC_DOLT_AUTO_GC_ENABLED")); ok { doltConfig.AutoGCEnabled = &parsed } }`` + - note: CONFIG WINS: env only fills the nil *bool. This is the env-fills-nil idiom the rollout Mode(Auto) should model. +- **parseEnvAutoGCEnabled — divergent parser #1 (ON/OFF + strconv.ParseBool)** — `cmd/gc/dolt_start_managed.go:992-1007` + - shape: `func parseEnvAutoGCEnabled(raw string) (value, ok bool) — TrimSpace; ''→(false,false); switch ToUpper(raw){case "ON":return true,true; case "OFF":return false,true}; then strconv.ParseBool(raw): err→(false,false) else (parsed,true). Doc comment: 'Accepts Go bool spellings (strconv.ParseBool) plus Dolt's ON/OFF.'` + - note: Accepts ON/OFF; REJECTS yes/no/y/n/enabled/disabled. Silent on invalid (no warning). +- **GC_DOLT_AUTO_GC_ENABLED child-env strip + re-emit (round-trip through subprocess)** — `cmd/gc/beads_provider_lifecycle.go:2003 (strip in key list) and :2038-2039 (re-emit)` + - shape: `func providerLifecycleProcessEnvFromBase(cityPath, provider string, env []string) []string {starts :1980}. Strips key "GC_DOLT_AUTO_GC_ENABLED" (:2003) then: `if dc.AutoGCEnabled != nil { env = append(env, fmt.Sprintf("GC_DOLT_AUTO_GC_ENABLED=%t", *dc.AutoGCEnabled)) }` reading dc from cityDoltConfigs.Load(cityPath) (config.DoltConfig at :2033).` + - note: %t → 'true'/'false' on the wire; parseEnvAutoGCEnabled's ParseBool branch consumes it in the child. Any rollout re-spelling must keep the parser compatible. +- **DoltConfig.AutoGCEnabled config field + resolver** — `internal/config/config.go:1841 (field), :1882-1888 EffectiveAutoGCEnabled, :1890-1895 AutoGCEnabledString (ON/OFF writer)` + - shape: `AutoGCEnabled *bool `toml:"auto_gc_enabled,omitempty" jsonschema:"default=true"` ; func (d DoltConfig) EffectiveAutoGCEnabled() bool {if d.AutoGCEnabled != nil {return *d.AutoGCEnabled}; return true}` + - note: Nil-defaults-true tri-state *bool. Mirror this shape for the new [beads] conditional_writes *bool field (IsDefined = field != nil). +- **GC_EVENTS_ROTATION_ENABLED read site (env-OVERRIDES precedence)** — `cmd/gc/providers.go:990-1005` + - shape: `func eventsRotationSettingsFromConfig(eventsCfg config.EventsConfig, stderr io.Writer) eventsRotationSettings {defined :988}. Seeds `settings.enabled = rot.EnabledOrDefault()` (:991-993) then `if raw, ok := os.LookupEnv("GC_EVENTS_ROTATION_ENABLED"); ok { if parsed, parseOK := parseEventsRotationEnabled(raw); parseOK { settings.enabled = parsed } else { warnEventsRotation(stderr, "...ignoring invalid GC_EVENTS_ROTATION_ENABLED=%q\n", raw) } }` (:998-1005)` + - note: ENV WINS over config. Opposite precedence to AutoGC. Uses os.LookupEnv (presence check) not os.Getenv. +- **parseEventsRotationEnabled — divergent parser #2 (hand-rolled token sets, ToLower)** — `cmd/gc/providers.go:1027-1036` + - shape: `func parseEventsRotationEnabled(raw string) (bool, bool) — switch ToLower(TrimSpace(raw)): case "1","t","true","y","yes","on","enabled": return true,true; case "0","f","false","n","no","off","disabled": return false,true; default: return false,false` + - note: Accepts y/yes/n/no/enabled/disabled that parser #1 rejects. Warns-then-keeps-default on invalid (via warnEventsRotation). +- **warnEventsRotation — operator-warning sink for invalid rotation env** — `cmd/gc/providers.go:1041-1046` + - shape: `func warnEventsRotation(stderr io.Writer, format string, args ...any) { if stderr == nil { return }; fmt.Fprintf(stderr, format, args...) //nolint:errcheck // best-effort operator warning }` + - note: Precedent for the rollout resolver's 'invalid flag value' operator feedback path; AutoGC has no equivalent (silent). +- **EventsRotationConfig.Enabled config field + EnabledOrDefault** — `internal/config/config.go:1747-1769` + - shape: `type EventsRotationConfig struct { Enabled *bool `toml:"enabled,omitempty" jsonschema:"default=true"` ... } ; func (c EventsRotationConfig) EnabledOrDefault() bool { if c.Enabled == nil { return true }; return *c.Enabled }` + - note: Same nil-defaults-true *bool tri-state as DoltConfig.AutoGCEnabled — the canonical config shape to reuse for conditional_writes. +- **GC_ALLOW_PROD_DOLT_PORT_IN_TESTS constant declaration** — `internal/testenv/testenv.go:144 (const), 138-143 (doc)` + - shape: `const ProdDoltPortOptOutVar = "GC_ALLOW_PROD_DOLT_PORT_IN_TESTS" ; paired const ProdDoltPort = "3307" at :135` + - note: Exposed as a named const, not a raw literal. Follow this: rollout flag NAMES should be exported consts, not inline strings. +- **GC_ALLOW_PROD_DOLT_PORT_IN_TESTS consumption (guard bypass, exact =="1")** — `internal/testenv/testenv.go:191-211 (refuseProdDoltPort), disarm check :193` + - shape: `func refuseProdDoltPort(survives func(name string) bool) { if os.Getenv(ProdDoltPortOptOutVar) == "1" { return } ; for portVar, hostVar := range doltPortVars { ... panic(...) } }. Called from init() at :224 (go-test mode: survives=passthrough-list) and :219 (testscript mode: survives=always-true).` + - note: THIRD shape: not env-fills-nil nor env-overrides — a test-only guard BYPASS on literal '1'. 'true'/'on'/'yes' do NOT disarm. Semantics invert (this turns a guard OFF). +- **testenv.LeakVectorVars — the scrub list (none of the 3 gates appear here)** — `internal/testenv/testenv.go:105-131` + - shape: `var LeakVectorVars = []string{ "BEADS_DIR", ... "GC_DOLT", "GC_DOLT_HOST", "GC_DOLT_PASSWORD", "GC_DOLT_PORT", "GC_DOLT_USER", ... "GC_TMUX_SESSION" }` + - note: GC_DOLT_AUTO_GC_ENABLED, GC_EVENTS_ROTATION_ENABLED, GC_ALLOW_PROD_DOLT_PORT_IN_TESTS are deliberately ABSENT. Do not add rollout toggles here; this list is city-path/session/store leak vectors only. +- **init() scrub + guard driver (where LeakVectorVars is applied)** — `internal/testenv/testenv.go:215-240` + - shape: `func init() { if !isGoTestBinary() { refuseProdDoltPort(func(string) bool { return true }); return }; keep := parse(PassthroughVar); os.Unsetenv(PassthroughVar); refuseProdDoltPort(func(name string) bool { return keep[name] }); for _, name := range LeakVectorVars { if !keep[name] { os.Unsetenv(name) } } }` + - note: The prod-port opt-out short-circuits BEFORE the LeakVectorVars scrub loop, confirming it is a gate, not a scrubbed var. +- **CI-tooth pattern to mirror for the rollout registry (registry↔invariant coupling test)** — `internal/testenv/testenv_internal_test.go:66-89 (TestDoltPortVarsAreLeakVectors); paired guard-comment at testenv.go:99-104 and 150-154` + - shape: `func TestDoltPortVarsAreLeakVectors(t *testing.T){ leak:=map[string]bool{...from LeakVectorVars}; for portVar,hostVar := range doltPortVars { if !leak[portVar]{t.Errorf(...)}; if hostVar!="" && !leak[hostVar]{t.Errorf(...)} } }` + - note: This is the exact 'every registered entry must satisfy a paired invariant, fails silently in the dangerous direction otherwise' test shape the rollout Spec-registry lifecycle/expiry CI tooth should copy. +- **doltPortVars — the port→host pairing map the guard iterates** — `internal/testenv/testenv.go:156-160` + - shape: `var doltPortVars = map[string]string{ "BEADS_DOLT_PORT": "", "BEADS_DOLT_SERVER_PORT": "BEADS_DOLT_SERVER_HOST", "GC_DOLT_PORT": "GC_DOLT_HOST" }` + - note: Not one of the 3 target gates, but it is the 'other half' of the prod-port guard the opt-out disarms; context for how ProdDoltPortOptOutVar interacts with the scrub. + + GOTCHAS: + - THREE DIVERGENT PARSERS, none shared. (a) parseEnvAutoGCEnabled (dolt_start_managed.go:992-1007): TrimSpace; ''→(false,false); ToUpper switch ON→(true,true)/OFF→(false,true); else strconv.ParseBool (accepts 1/t/T/TRUE/true/0/f/F/FALSE/false) → err yields (false,false). Accepts ON/OFF but REJECTS yes/no/y/n/enabled/disabled. (b) parseEventsRotationEnabled (providers.go:1027-1036): ToLower(TrimSpace); true-set {1,t,true,y,yes,on,enabled}; false-set {0,f,false,n,no,off,disabled}; else (false,false). Accepts yes/no/y/n/enabled/disabled that (a) rejects. (c) prod-port opt-out: exact string `os.Getenv(ProdDoltPortOptOutVar) == "1"` (testenv.go:193) — 'true'/'yes'/'TRUE'/'on' do NOT disarm. Any unification must preserve or consciously widen each accepted-token set. + - DIVERGENT PRECEDENCE. GC_DOLT_AUTO_GC_ENABLED = env-fills-nil (config wins; env consulted only when AutoGCEnabled==nil). GC_EVENTS_ROTATION_ENABLED = env-overrides (env wins over config's EnabledOrDefault()). Absorbing both under one resolver without preserving this asymmetry silently changes behavior. + - AutoGC ROUND-TRIPS through child env. After config resolution, beads_provider_lifecycle.go strips GC_DOLT_AUTO_GC_ENABLED from the child env (in the key list at :2003) then RE-EMITS it from config at :2038-2039 via `fmt.Sprintf("GC_DOLT_AUTO_GC_ENABLED=%t", *dc.AutoGCEnabled)` — note `%t` writes 'true'/'false', which the child's parseEnvAutoGCEnabled ParseBool branch accepts. If the rollout resolver changes the on-wire spelling (e.g. to ON/OFF or 1/0) the parser must still accept it. + - NONE of the three vars is in testenv.LeakVectorVars (testenv.go:105-131). GC_DOLT_* leak vectors there are GC_DOLT/GC_DOLT_HOST/GC_DOLT_PASSWORD/GC_DOLT_PORT/GC_DOLT_USER — NOT GC_DOLT_AUTO_GC_ENABLED, and no GC_EVENTS_* at all. AutoGC/rotation are behavior toggles (safe to inherit); the prod-port opt-out is a deliberate NON-scrubbed opt-out gate (documented alongside test-gate vars GC_FAST_UNIT etc. at testenv.go:26-28). Do NOT add rollout flags to LeakVectorVars expecting them to be scrubbed — that list is for city-path/session/store leak vectors only. + - GC_ALLOW_PROD_DOLT_PORT_IN_TESTS is exposed as the const ProdDoltPortOptOutVar (testenv.go:144), consumed once in refuseProdDoltPort (testenv.go:191-211 body; check at :193). It is a guard BYPASS, not a feature flag that turns something on — semantics invert vs the other two. The lifecycle/expiry CI-tooth pattern to mirror for the rollout registry is TestDoltPortVarsAreLeakVectors (testenv_internal_test.go:66-89): a table/registry-coupling test that fails if a registered entry is missing its paired invariant. + - Rotation env-override emits an operator WARNING on invalid input via warnEventsRotation (providers.go:1041-1046, best-effort Fprintf, //nolint:errcheck) then keeps the config default. AutoGC parser is SILENT on invalid input (ok=false → keeps default, no warning). doctor/minimal-output work should decide whether absorbed flags warn or stay silent — current behavior diverges. + +## di-test-seam + +**Idiom to follow:** Config in gascity is NEITHER a global singleton NOR context-passed: the loaded `*config.City` is a STRUCT FIELD on the runtime object (`controllerState.cfg` at cmd/gc/api_state.go:46, `CityRuntime.cfg` at cmd/gc/city_runtime.go:68), read via a `Config() *config.City` accessor (api_state.go:1014). The anti-pattern the DESIGN kills is that the DERIVED flag is NOT threaded with the config — instead `applyFeatureFlags`/`syncFeatureFlags` dereference `cfg.Daemon.FormulaV2Enabled()` and shove the bool into a PACKAGE-LEVEL `atomic.Bool` global in a downstream package (`internal/formula` compile.go:623, `internal/molecule` graph_apply.go:25) via `SetFormulaV2Enabled`/`SetGraphApplyEnabled`. That process-global is what forces the process-wide `sync.Mutex` (`formulatest.v2Mu`, v2.go:12) and ~20 save/restore blocks in tests. rollout.Resolve MUST instead return an IMMUTABLE `Flags` VALUE (no global, no Set, no atomic.Bool) that is threaded by DI and stamped per-instance onto each store the beads factory opens (extend beads.StoreOpenOptions, factory.go:52-63, with a `ConditionalWrites rollout.Mode` field — that is the per-store, per-instance seam that matches the multi-store reality). For env injection follow the webhookverify.SecretResolver idiom (secret.go:45-59): an injected `func(string)(string,bool)` mirroring os.LookupEnv, defaulting to os.LookupEnv when nil — the (value,ok) shape is required so `require`/strict-grammar can distinguish unset from empty and so `fills-nil` vs `overrides` semantics are expressible. Tests build Flags per-instance via `rollout.ForTest(t, rollout.With...())` — no shared state, no mutex, no cleanup, t.Parallel-safe by construction, exactly mirroring the registry's defensive-copy discipline (Specs() returns a copy; Validate/Resolve/ForTest take a []Spec param). + +- **THE ANTI-PATTERN #1: package-global atomic.Bool feature flag (what rollout.Flags replaces). init() stores true; mutated process-wide by Set. This is why tests must serialize.** — `internal/formula/compile.go:623` + - shape: `var formulaV2Enabled atomic.Bool; func init(){formulaV2Enabled.Store(true)}; func SetFormulaV2Enabled(v bool) (line 632); func IsFormulaV2Enabled() bool (line 638)` + - note: Comment (compile.go:620-622) rationalizes the global as reload-race-safe — the exact mid-run flip the DESIGN forbids (mode must be process-latched to a boot snapshot). +- **THE ANTI-PATTERN #2: second package-global atomic.Bool, kept in lockstep with #1 by hand. NO mutex guards it — molecule_test.go does ~20 raw prev/set/Cleanup blocks that are NOT parallel-safe.** — `internal/molecule/graph_apply.go:25` + - shape: `var graphApplyEnabled atomic.Bool; func SetGraphApplyEnabled(v bool) (line 30); func IsGraphApplyEnabled() bool (line 36)` + - note: Two globals that MUST agree is itself the smell; DESIGN §1.3 cites 'two package-level atomic.Bools'. +- **THE PROCESS-WIDE MUTEX (the symptom the DESIGN names). Serializes every test that touches formula_v2 because the flag is a shared global; returns a setter; restores prev on Cleanup.** — `internal/formulatest/v2.go:12` + - shape: `var v2Mu sync.Mutex; func LockV2ForTest(tb testing.TB) func(enabled bool) (line 17) — Lock+snapshot prev, Cleanup unlocks+restores; SetV2ForTest/EnableV2ForTest/HoldV2ForTest wrappers` + - note: rollout.ForTest must need NO mutex: a per-instance Flags value has nothing to serialize. This file is the 'before' the DI seam deletes. +- **Composition root #1 (CLI/daemon): the single derive-and-scatter helper. Reads cfg struct field, pushes into two globals. Called at 8 non-test sites.** — `cmd/gc/feature_flags.go:12` + - shape: `func applyFeatureFlags(cfg *config.City) { gw := cfg.Daemon.FormulaV2Enabled(); formula.SetFormulaV2Enabled(gw); molecule.SetGraphApplyEnabled(gw) }` + - note: Non-test call sites: cmd_start.go:673, controller.go:923 (RELOAD — mid-run mutation), api_state.go:1808 (RELOAD), cmd_agent.go:52 & :70, cmd_sling.go:247, doctor_provider_catalog.go:146. rollout.Resolve is wired here. +- **Composition root #2 (API server): the duplicate derive-and-scatter root. Stomps the SAME globals on every server construction.** — `internal/api/server.go:229` + - shape: `func syncFeatureFlags(cfg *config.City){ enabled := cfg!=nil && cfg.Daemon.FormulaV2Enabled(); if formula.IsFormulaV2Enabled()!=enabled {formula.SetFormulaV2Enabled(enabled)}; if molecule.IsGraphApplyEnabled()!=enabled {molecule.SetGraphApplyEnabled(enabled)} }` + - note: Called from New() (server.go:197) and NewReadOnly() (server.go:203). This is the SECOND of the 'both composition roots' Resolve must be wired at. +- **CROSS-TEST LEAKAGE IN THE WILD: because New() calls syncFeatureFlags which stomps the global, tests must RE-SET the flag AFTER building the server or it gets overwritten.** — `internal/api/handler_sling_test.go:646` + - shape: `comment block 646-653: 'newSlingTestServer → New() → syncFeatureFlags(state.cfg) sets the package-global formula.IsFormulaV2Enabled ... so New()'s syncFeatureFlags doesn't stomp it'` + - note: Concrete evidence the global leaks across construction. Per-instance Flags eliminates this ordering hazard entirely. +- **IDIOM TO FOLLOW for injected env (os.LookupEnv shape, nil→default). This is the cleanest existing DI env seam and the right shape for rollout's strict-grammar/fills-nil resolution.** — `internal/webhookverify/secret.go:45` + - shape: `type SecretResolver struct { lookup func(string)(string,bool) }; func NewSecretResolver() *SecretResolver { return &SecretResolver{lookup: os.LookupEnv} } (51); func NewSecretResolverWithEnv(lookup func(string)(string,bool)) *SecretResolver (57); Resolve() falls back to os.LookupEnv when r.lookup==nil (76-78)` + - note: (value, ok) shape distinguishes unset from empty — REQUIRED for require/EnvOverrides vs EnvFillsNil. rollout.ResolveOptions.LookupEnv should be exactly this signature. +- **ALTERNATE env idiom (getenv, explicit param). WRONG shape for a correctness flag (can't tell unset from empty) but shows the codebase's DI-by-parameter convention.** — `cmd/gc/init_hosted_dolt.go:58` + - shape: `func resolveHostedDoltInitOptions(flags hostedDoltInitFlagValues, getenv func(string) string) hostedDoltInitOptions — 'getenv is injected for testability; production callers pass os.Getenv'` + - note: Pure function + injected getenv param, no globals, no t.Setenv. Same spirit as Resolve, but use the LookupEnv (value,ok) shape not this one. +- **ALTERNATE env idiom (struct field getenv, nil-fallback via helper method). Shows the struct-field-with-nil-default convention.** — `cmd/gc/jsonl_archive_doctor_check.go:28` + - shape: `type jsonlArchiveDoctorCheck struct { getenv func(string) string /* nil means os.Getenv */ }; func (c *jsonlArchiveDoctorCheck) env(key string) string { if c.getenv!=nil {return c.getenv(key)}; return os.Getenv(key) }` + - note: The nil-means-production-default pattern is idiomatic here; apply it to ResolveOptions.LookupEnv. +- **THE PER-INSTANCE STAMP POINT: the beads store factory options struct. Add `ConditionalWrites rollout.Mode` here so every opened store carries its own mode — matches DESIGN's 'beads factory stamps it onto every store it opens' and per-store capability reality.** — `internal/beads/factory.go:52` + - shape: `type StoreOpenOptions struct { ScopeRoot, CityPath, Provider string; PreflightChecker contract.PreflightChecker; Logger *slog.Logger; OpenBdStore/OpenFileStore/OpenExecStore/OpenNativeStore func()(Store,error) }; func OpenStoreAtForCity(ctx, opts StoreOpenOptions)(StoreOpenResult,error) (line 77)` + - note: This is the DI boundary where a resolved Flags value enters per-store. NOTE: beads must NOT import rollout (import direction rollout→config→...); the mode is a plain field set by the caller (cmd/gc), not a rollout type inside beads. +- **Config STRUCT-FIELD threading (proves config is not a singleton/context). controllerState holds cfg and exposes it; this is the object Resolve reads from at the API root.** — `cmd/gc/api_state.go:46` + - shape: `controllerState{ cfg *config.City; ... }; func (cs *controllerState) Config() *config.City (1014); func (cs *controllerState) RawConfig() *config.City (1162)` + - note: CityRuntime mirrors this: cfg *config.City at city_runtime.go:68. Resolve(cfg,...) reads from these fields, not a global. +- **Env-leak registry the DESIGN requires GC_BEADS_CONDITIONAL_WRITES to join. Package-init scrub + a lint test enforce it.** — `internal/testenv/testenv.go:105` + - shape: `var LeakVectorVars = []string{ "BEADS_DIR", ... "GC_TMUX_SESSION" } — scrubbed at package init()` + - note: Add GC_BEADS_CONDITIONAL_WRITES here; registry_test §3.6.6 asserts every non-empty Spec.EnvOverride appears in this list. TestDoltPortVarsAreLeakVectors (testenv_internal_test.go:66) is the pairing-guard template to mirror. +- **The AST lint mechanism the DESIGN reuses for the registry-driven prompt-boundary lint and the 'no LeakVector reads at init' guard.** — `internal/testenv/lint_test.go:166` + - shape: `func TestNoLeakVectorReadsAtPackageInit(t *testing.T) — walks AST, findLeakVectorGetenv(fset, node, leakVars, rel, &offenders) (line 339), derives leakVars from testenv.LeakVectorVars` + - note: This is the exact 'same mechanism as TestNoLeakVectorReadsAtPackageInit' the DESIGN §2.3 cites for the registry-driven AST lint over rollout.Flags accessors. +- **config load entrypoints (struct-returning, no env injection today). Resolve is called right after these at the roots; they do NOT take a LookupEnv — env handling stays inside rollout.Resolve, not the loader.** — `internal/config/compose.go:111` + - shape: `func LoadWithIncludes(fs fsys.FS, path string, extraIncludes ...string) (*City, *Provenance, error); func LoadWithIncludesOptions(fs, path, opts LoadOptions, extra...) (line 116); func Load(fs, path)(*City,error) (config.go:4223)` + - note: Loaders return the *City struct that becomes the threaded field. Env overlay is rollout's job (opts.LookupEnv), keeping loader untouched per DESIGN §4. + + GOTCHAS: + - Config is a STRUCT FIELD, not a singleton and not context — but the DERIVED flag today is a package-global atomic.Bool. Do not mirror the global; the whole point of the DI seam is that Flags is a value threaded from the struct field, never re-read from a global. + - There are TWO globals kept in hand-lockstep (formula.formulaV2Enabled + molecule.graphApplyEnabled) plus TWO derive-roots (applyFeatureFlags in cmd/gc, syncFeatureFlags in internal/api). Resolve must replace BOTH roots or the second silently stomps the first (proven by the handler_sling_test.go:646 re-set hack). + - The reload paths (controller.go:923, api_state.go:1808) call applyFeatureFlags AGAIN mid-run and re-mutate the global — the exact mid-process flip the DESIGN forbids. The reload path must carry the boot-resolved Flags snapshot forward, never re-Resolve the mode. + - molecule.graphApplyEnabled has NO mutex; molecule_test.go uses ~20 unguarded prev/Set/Cleanup blocks (lines 303,501,536,...1202+) that are not t.Parallel-safe and race the formula flag. Any freeze test must cover BOTH Set functions, not just formula's. + - Use os.LookupEnv shape func(string)(string,bool) (webhookverify.SecretResolver), NOT os.Getenv shape func(string)string (init_hosted_dolt) — a correctness gate needs to distinguish unset from empty for require/EnvFillsNil. Both idioms exist in-tree; pick the (value,ok) one deliberately. + - Import direction is load-bearing: rollout imports config (Resolve takes *config.City), so beads/factory.go must NOT import rollout. Stamp the mode onto StoreOpenOptions as a plain value set by the cmd/gc caller, or beads gains a forbidden upward dependency. + - ForTest must take/produce per-instance state only (mirror Specs() defensive-copy + Validate([]Spec) param discipline). If ForTest touches any package var it reintroduces the v2Mu serialization it exists to delete; the DESIGN's 'no string-keyed override, breaks tests at compile time' requires typed With* options, not a map. + +## prompt-extraction-and-boundary (PR-1a): extract cmd/gc prompt rendering into internal/prompt so the internal/prompt → internal/rollout import-boundary test becomes real and non-vacuous + +**Idiom to follow:** Mechanical file move of cmd/gc/prompt.go into a new internal/prompt package, exporting its cross-file symbols, with ONE non-mechanical seam: the promptFuncMap `session` template func's dependency on cmd/gc's lookupSessionNameOrLegacy must become an injected resolver func param (keep the session-bead lookup impl in cmd/gc). The import-boundary test mirrors internal/runtime/import_boundary_test.go verbatim: iterate the package's non-test *.go files, parser.ParseFile(fset, name, nil, parser.ImportsOnly), fail if any import path equals internal/rollout. prompt.go already imports ZERO cmd/gc-package-main symbols in its import block (only internal/beads, config, fsys, git, promptmeta) — the only package-main coupling is the single unqualified call to lookupSessionNameOrLegacy inside promptFuncMap. + +- **THE extraction target file — entire file is package main and holds every symbol the DESIGN names (renderPrompt, buildTemplateData, PromptContext). Moving it verbatim to internal/prompt is ~90% mechanical.** — `cmd/gc/prompt.go:1-508` + - shape: `package main; imports internal/{beads,config,fsys,git,promptmeta} + stdlib only — NO package-main import` + - note: Import block (lines 3-18) is already cmd/gc-clean; the file compiles against internal packages, so relocating it does not create an import cycle. +- **PromptContext — the struct the boundary test protects; its open Env map[string]string is the value-leak vector the PR-1b AST lint guards (no rollout.Flags accessor may flow into Env).** — `cmd/gc/prompt.go:26-61` + - shape: `type PromptContext struct { CityRoot, AgentName, TemplateName, BindingName, BindingPrefix, RigName, RigRoot, WorkDir, IssuePrefix, Branch, DefaultBranch, WorkQuery, AssignedInProgressQuery, AssignedReadyQuery, RoutedPoolQuery, SlingQuery, ProviderKey, ProviderDisplayName, InstructionsFile string; Env map[string]string }` + - note: Constructed at exactly 3 non-test sites: cmd_lint.go:396, template_resolve.go:343, cmd_prime.go:665. Must be exported. +- **renderPrompt — thin wrapper returning .Text; the primary public entry point for prompt rendering.** — `cmd/gc/prompt.go:87-89` + - shape: `func renderPrompt(fs fsys.FS, cityPath, cityName, templatePath string, ctx PromptContext, sessionTemplate string, stderr io.Writer, packDirs []string, injectFragments []string, store beads.Store) string` + - note: Two production callers: template_resolve.go:343 and cmd_prime.go:318. Export as prompt.Render. +- **renderPromptWithMeta — the real implementation (parse frontmatter, load shared/fragment templates, execute, append inject fragments); returns version+SHA provenance.** — `cmd/gc/prompt.go:95-214` + - shape: `func renderPromptWithMeta(...same args...) PromptRenderResult` + - note: Currently NO production caller besides renderPrompt (only prompt_meta_test.go), despite the 'callers persisting provenance' doc comment — so exporting it costs nothing to callers. PromptRenderResult{Text,Version,SHA} at prompt.go:71-75. +- **buildTemplateData — merges PromptContext.Env (low priority) with 18 SDK fields (high priority) into map[string]string for tmpl.Execute. Named explicitly by the DESIGN as an extraction target.** — `cmd/gc/prompt.go:317-343` + - shape: `func buildTemplateData(ctx PromptContext) map[string]string` + - note: Cross-file caller: cmd_lint.go:347 (gc lint re-implements rendering inline). Must be exported. +- **promptFuncMap — THE ONLY non-mechanical seam. Its `session` closure calls cmd/gc-package-main lookupSessionNameOrLegacy; every other func uses stdlib + config.ParseQualifiedName (already exported).** — `cmd/gc/prompt.go:392-434 (session closure at 397-399)` + - shape: `func promptFuncMap(cityName, sessionTemplate string, store beads.Store, parentTmpl func() *template.Template) template.FuncMap — funcs: cmd, session, basename, templateFirst` + - note: session at line 398 = lookupSessionNameOrLegacy(store, cityName, agentName, sessionTemplate). Cross-file caller cmd_lint.go:327 passes nil store. FIX: add a resolver param `sessionName func(store beads.Store, cityName, qualifiedName, sessionTemplate string) string` and inject cmd/gc's lookupSessionNameOrLegacy at the call sites. +- **lookupSessionNameOrLegacy — the injected-seam impl that must STAY in cmd/gc (moving it drags internal/session + the ~700-line session-bead reading machinery into internal/prompt).** — `cmd/gc/session_name_lookup.go:710-715` + - shape: `func lookupSessionNameOrLegacy(store beads.Store, cityName, qualifiedName, sessionTemplate string) string { if sn,ok:=lookupSessionName(store,qualifiedName); ok {return sn}; return agent.SessionNameFor(cityName,qualifiedName,sessionTemplate) }` + - note: Transitively depends on lookupSessionName (:696) / findSessionNameByTemplate (:616) which read session beads; file imports internal/{agent,beads,config,session}. This transitive weight is exactly why DI beats moving it. 8 other cmd/gc call sites keep using it directly. +- **PRIMARY agent-start render path — the real session-creation prompt render. resolveTemplate builds the full PromptContext and calls renderPrompt.** — `cmd/gc/template_resolve.go:343-363 (enclosing func resolveTemplate at :146; driven by build_desired_state.go:4438)` + - shape: `func resolveTemplate(p *agentBuildParams, cfgAgent *config.Agent, qualifiedName string, fpExtra map[string]string) (TemplateParams, error)` + - note: This is THE agent-start path (Step 9 'Render prompt with beacon'). After renderPrompt returns, the caller prepends the beacon (:371) and appends the assigned-skills fragment (:417) — those stay in cmd/gc; only the render call crosses the new boundary. +- **SECOND render path — gc prime (hook-mode prompt emission to stdout).** — `cmd/gc/cmd_prime.go:318-319 (context built by buildPrimeContextForBeads at :664)` + - shape: `renderPrompt(fsys.OSFS{}, cityPath, cityName, a.PromptTemplate, ctx, cfg.Workspace.SessionTemplate, stderr, packDirs, fragments, nil)` + - note: Passes nil store (no session lookup). buildPrimeContext (:660) and buildPrimeContextForBeads (:664) are PromptContext factories that stay in cmd/gc (they read GC_* env). +- **THIRD path — gc lint. Does NOT call renderPrompt; re-implements the render loop inline using promptFuncMap + buildTemplateData + its own lintLoadSharedTemplates.** — `cmd/gc/cmd_lint.go:326-362 (lintPromptContext factory at :386)` + - shape: `template.New("prompt").Funcs(promptFuncMap("lint-city","",nil,...)).Option("missingkey=zero"); ... buildTemplateData(lintPromptContext(...))` + - note: Depends on the most exported symbols of any caller: promptFuncMap, buildTemplateData, isPromptTemplatePath, promptTemplateSourcePath, sharedTemplateFileNames, promptSourcePackRoot, providerDisplayNameFor, effectivePromptFragments. Confirms the export surface is large. +- **Cross-file helper export surface — the tax that makes PR-1a bigger than a pure move. Every symbol below has ≥1 caller in another cmd/gc file and must be exported (or kept in cmd/gc).** — `cmd/gc/prompt.go (providerInfoForAgent:441, instructionsFileForAgent:462, providerDisplayNameFor:491, findRigPrefix:347, defaultBranchFor:358, defaultBranchForRig:371, effectivePromptFragments:308, mergeFragmentLists:287, isPromptTemplatePath:246, promptTemplateSourcePath:216, sharedTemplateFileNames:250, promptSourcePackRoot:223)` + - shape: `callers: providerInfoForAgent/instructionsFileForAgent/findRigPrefix/defaultBranchForRig ← cmd_prime.go+template_resolve.go; providerDisplayNameFor/isPromptTemplatePath/sharedTemplateFileNames/promptSourcePackRoot ← cmd_lint.go; promptTemplateSourcePath ← cmd_lint.go+cmd_prime.go; defaultBranchFor ← cmd_sling.go; effectivePromptFragments ← lint+prime+template_resolve; mergeFragmentLists ← agent_build_params.go` + - note: ~18 symbols total need exporting. providerInfoForAgent/instructionsFileForAgent/providerDisplayNameFor are pure config-only funcs (safe to move); the provider helpers only import internal/config so they carry no new dependency weight. +- **IMPORT-BOUNDARY TEST idiom to mirror — the strongest in-repo precedent for `internal/prompt imports internal/rollout → red`.** — `internal/runtime/import_boundary_test.go:25-57` + - shape: `os.ReadDir('.'); for each non-test .go: parser.ParseFile(fset, name, nil, parser.ImportsOnly); range file.Imports; strings.Trim(imp.Path.Value,`"`); t.Errorf on forbidden path` + - note: Copy this shape into internal/prompt/import_boundary_test.go, forbidding internal/rollout. The DESIGN's 'general core' rule also needs the mirror test in internal/rollout forbidding internal/beads — same idiom. cityinit/no_io_boundary_test.go:14-25 is a second example (also does full-AST parse for deeper checks). +- **SECOND boundary-test idiom (string-needle scan) — AGENTS.md's cited worker-boundary guard; simpler but less robust than the AST version.** — `cmd/gc/worker_boundary_import_test.go:11-58 (TestGCNonTestFilesStayOnWorkerBoundary)` + - shape: `os.ReadDir(dir); skip _test.go; strings.Contains(content, needle) → t.Fatalf` + - note: Use the AST ImportsOnly version for the prompt boundary (imports are exact package paths); reserve string-scan for the PR-1b AST lint over PromptContext construction / Env writes / FuncMap closures. +- **AST-lint precedent named by the DESIGN for PR-1b (registry-driven check that no PromptContext build / Env write / FuncMap references a rollout.Flags accessor). Not PR-1a, but the boundary work sets it up.** — `internal/testenv/lint_test.go + testenv.go (TestNoLeakVectorReadsAtPackageInit / LeakVectorVars)` + - shape: `go/ast walk over package init/decls checking for forbidden reads` + - note: PR-1a only makes the import edge exist; the value-flow lint reusing this mechanism is PR-1b. +- **Test files that couple to the extracted symbols and must move to internal/prompt (or the package_test) — heaviest is prompt_test.go.** — `cmd/gc/prompt_test.go (~140 refs), cmd/gc/prompt_meta_test.go (21 refs, only caller of renderPromptWithMeta), cmd/gc/main_test.go (2), cmd/gc/cmd_prime_test.go (1)` + - shape: `package main tests calling renderPrompt/renderPromptWithMeta/buildTemplateData/promptFuncMap/loadSharedTemplates/effectivePromptFragments directly` + - note: prompt_test.go + prompt_meta_test.go move wholesale to internal/prompt; cmd_prime_test.go/main_test.go keep their 1-2 refs pointed at the new exported names. New package needs a generated testenv_import_test.go (per MEMORY: TestRequiresDedicatedTestenvImportFile / run `go run scripts/add-testenv-import.go`). + + GOTCHAS: + - The extraction is NOT a pure `git mv`: ~18 helper symbols in prompt.go have cross-file callers in package main (cmd_lint.go, cmd_prime.go, template_resolve.go, cmd_sling.go, agent_build_params.go), so each must be exported and every caller updated. Grep-verified caller table is in the map. Budget for a wide but shallow rename sweep. + - The ONE genuinely non-mechanical decision: promptFuncMap's `session` closure (prompt.go:398) calls package-main lookupSessionNameOrLegacy (session_name_lookup.go:710), which transitively imports internal/session + the ~700-line session-bead lookup machinery. Do NOT move that function — inject it as a resolver func param and wire cmd/gc's impl at the 2 promptFuncMap call sites (prompt.go internal + cmd_lint.go:327 which passes nil/no-op). + - prompt.go's import block is already free of any cmd/gc-package-main dependency (only internal/beads, config, fsys, git, promptmeta + stdlib), so relocation creates no import cycle — the sole coupling is the unqualified lookupSessionNameOrLegacy call, not an import. + - The DESIGN (§2.3, line 223) is explicit that the import test is vacuous/permanently-red UNLESS extraction happens first, because rendering currently lives in the SAME package main as the rollout.Resolve composition root. Skipping PR-1a and relying only on the AST lint is the documented fallback (see open decision at DESIGN line 2548) — but the milestone's stated intent is to do the extraction so the structural edge is real. + - gc lint (cmd_lint.go:326-362) re-implements the render loop inline rather than calling renderPrompt, and depends on the LARGEST set of would-be-exported helpers (promptFuncMap, buildTemplateData, isPromptTemplatePath, sharedTemplateFileNames, promptSourcePackRoot, providerDisplayNameFor, promptTemplateSourcePath). It is the caller most sensitive to the export decisions — verify it compiles after the move. + - renderPromptWithMeta has no production caller besides renderPrompt today (only prompt_meta_test.go), so its doc comment about 'callers persisting provenance' is aspirational — exporting it is free but don't assume a live provenance consumer exists to break. + - New package internal/prompt will trip the pre-push TestRequiresDedicatedTestenvImportFile guard (MEMORY: new-test-package-testenv-import) — run `go run scripts/add-testenv-import.go` to generate testenv_import_test.go, or a targeted `go test ./internal/prompt/` will pass locally while the push hook rejects it. + +## doctor-deps-version: (a) gc doctor structure — where a rollout/flags report hangs + the check/finding idiom; (b) deps.env BD_VERSION+SHA + TestBDVersionPins version-lockstep and how a removal predicate wires in + +**Idiom to follow:** A doctor Check is a plain Go type implementing doctor.Check (Name/Run/CanFix/Fix/WarmupEligible). In-package checks (internal/doctor/*.go) take a `New…Check(...)` constructor and build a `*doctor.CheckResult` directly; cmd/gc checks use the local `okCheck/warnCheck/errorCheck(name,message,hint,details)` helpers (cmd/gc/doctor_v2_checks.go:1246-1268). Register every check in `buildDoctorChecks` (cmd/gc/cmd_doctor.go:187) via the `register` closure; nothing else wires checks. Optional table output (the natural home for a per-store CAS capability verdict + origin) is the Renderer interface (types.go:76-78), implemented like PostgresAuthCheck.RenderExtras (postgres_auth.go:108) and gated by a CheckContext flag. JSON output is a separate mirror struct (doctorJSONResult, cmd_doctor.go:544) projected in writeDoctorJSON — a new rollout summary would extend that path, not hand-roll JSON. For the version tooth: mirror `bdReadyProjectionMinVersion` (internal/beads/bdstore_ready_projection.go:10) exactly — a Go string const gated with `deps.CompareVersions(probed, anchor) >= 0` — and register the new anchor in the TestBDVersionPins cross-check (scripts/bd_version_pin_test.go:23) via `extractGoStringConst`, with deps.env (BD_VERSION / BD_PREV_VERSION) as the single source of truth. The removal/graduation predicate is a plain deterministic Go test comparing deps.env anchors with deps.CompareVersions — never time.Now(). + +- **Check interface — the contract a rollout/flags doctor check must implement** — `internal/doctor/types.go:36` + - shape: `type Check interface { Name() string; Run(ctx *CheckContext) *CheckResult; CanFix() bool; Fix(ctx *CheckContext) error; WarmupEligible() bool }` + - note: 5 methods. Most checks return CanFix()=false / Fix()=nil no-op / WarmupEligible()=false. A rollout report is read-only → CanFix false. +- **CheckResult — the finding shape (message/severity/details/hint)** — `internal/doctor/types.go:80` + - shape: `type CheckResult struct { Name string; Status CheckStatus; Severity CheckSeverity; Message string; Details []string; FixHint string; FixError string; FixAttempted bool; Fixed bool }` + - note: Details []string are the per-line rows (verbose-only) — the natural place to list one row per registered Spec (key=mode (origin)) or per-store capability verdict. Severity zero-value = SeverityBlocking; set SeverityAdvisory for a non-gating informational flags report, SeverityBlocking for the require-∧-incapable FAIL. +- **CheckStatus + CheckSeverity enums (ok/warning/error; blocking/advisory)** — `internal/doctor/types.go:8` + - shape: `const StatusOK/StatusWarning/StatusError CheckStatus; const SeverityBlocking(=0)/SeverityAdvisory CheckSeverity` + - note: DESIGN maps: require∧incapable→StatusError+SeverityBlocking (nonzero exit); auto-degraded→StatusWarning; off/ok→StatusOK. Blocking failures gate exit code (doctor.go:106, report.BlockingFailed). +- **CheckContext — shared per-run state; carries an opt-in render flag precedent** — `internal/doctor/types.go:54` + - shape: `type CheckContext struct { CityPath string; Verbose bool; Output io.Writer; ExplainPostgresAuth bool }` + - note: ExplainPostgresAuth is the precedent for adding an opt-in flag (e.g. an --explain-conditional-writes) that gates a Renderer table. CityPath is the only city input a self-contained rollout check needs to call rollout.Resolve(cfg,...). +- **Renderer — optional table output beyond the one-line result (per-store CAS verdict/origin table)** — `internal/doctor/types.go:76` + - shape: `type Renderer interface { RenderExtras(ctx *CheckContext, w io.Writer) }` + - note: Doctor.run type-asserts each check and calls RenderExtras after printResult (doctor.go:90-92). This is where DESIGN's per-store capability array (store=graph kind=sqlite capable=false reason=…) and origin rows would render. +- **PostgresAuthCheck.RenderExtras — worked example of the per-scope resolution TABLE idiom** — `internal/doctor/postgres_auth.go:108` + - shape: `func (c *PostgresAuthCheck) RenderExtras(ctx *CheckContext, w io.Writer)` + - note: Copy this shape for the rollout capability/origin table (columns, gating on a CheckContext bool, no secrets printed). Tests at internal/doctor/postgres_auth_test.go:211+. +- **Doctor runner: Register + Run/RunCollect + BlockingFailed→exit** — `internal/doctor/doctor.go:29` + - shape: `type Doctor struct{ checks []Check }; func (d *Doctor) Register(c Check); func (d *Doctor) Run(ctx,*w,fix) *Report; func (d *Doctor) RunCollect(ctx,fix) *Report` + - note: Report{Passed,Warned,Failed,BlockingFailed,Fixed,Results []*CheckResult} (doctor.go:9). doDoctor returns 1 iff report.BlockingFailed>0 (cmd_doctor.go:439) — a require-mode CAS failure must be StatusError+SeverityBlocking to gate exit. +- **buildDoctorChecks — THE registration site where a rollout/flags check hangs** — `cmd/gc/cmd_doctor.go:187` + - shape: `func buildDoctorChecks(cityPath string, cfg *config.City, cfgErr error, opts buildDoctorChecksOpts) []doctor.Check // local `register := func(c doctor.Check){checks=append(...)}` at :189` + - note: Core always-run checks register at :198-208; config-dependent (cfg loaded cleanly) at :212-238. A rollout summary check (reads resolved Flags + per-store capability) slots into the config-dependent block near :218 (NewConfigValidCheck) since it needs cfg. This is the only place checks are wired. +- **doDoctor — command entrypoint: constructs Doctor, loops register→d.Register, picks JSON vs streaming, sets exit** — `cmd/gc/cmd_doctor.go:400` + - shape: `func doDoctor(fix, verbose, jsonOut, explainPostgresAuth bool, stdout, stderr io.Writer) int` + - note: d.Register loop at :417-425; jsonOut→RunCollect+writeDoctorJSON, else Run+PrintSummary; `return 1` iff report.BlockingFailed>0 (:439). newDoctorCmd flags at cmd_doctor.go:59-63 (a new --explain-* flag would be added alongside). +- **Finding helpers — the exact idiom for building results in cmd/gc doctor checks** — `cmd/gc/doctor_v2_checks.go:1246` + - shape: `func okCheck(name,message string) *doctor.CheckResult; func warnCheck(name,message,hint string,details []string) *doctor.CheckResult; func errorCheck(name,message,hint string,details []string) *doctor.CheckResult` + - note: Use these in a cmd/gc-resident rollout check rather than hand-building CheckResult. errorCheck→StatusError, warnCheck→StatusWarning. Note: these helpers do NOT set Severity, so they default to SeverityBlocking — set r.Severity=SeverityAdvisory manually for an advisory flags listing. +- **Canonical in-package Check example — constructor + Name/Run/CanFix/Fix, FixHint citing an issue** — `internal/doctor/checks_provider_parity.go:25` + - shape: `type ProviderParityCheck struct{ cfg *config.City }; func NewProviderParityCheck(cfg *config.City) *ProviderParityCheck; func (c *ProviderParityCheck) Run(_ *CheckContext) *CheckResult` + - note: Best template if the rollout check lives in internal/doctor: builds &CheckResult{Name:...} directly, sets Status/Message/Details/FixHint. FixHint references config+issue — mirror by pointing at internal/rollout/registry.go + the flag Key. +- **Minimal struct-check example (no config, no fix) for a self-contained report** — `cmd/gc/doctor_v2_checks.go:1048` + - shape: `type v2ScriptsLayoutCheck struct{}; Name()string; CanFix()bool{return false}; Fix(_)error{return nil}; Run(ctx)*CheckResult` + - note: Smallest complete Check in cmd/gc. WarmupEligible for such cmd/gc structs is often defined in a sibling; for a doctor-only rollout report return false (not part of `gc start` warm-up). +- **WarmupEligible default-false convention (one-liner per check)** — `internal/doctor/warmup_eligible.go:1` + - shape: `func (c *XCheck) WarmupEligible() bool { return false }` + - note: A rollout report is on-demand only → return false. PackScriptCheck.WarmupEligible returns c.Warmup (pack_checks.go:60) is the only data-driven one. +- **JSON output path + wire mirror struct (extend here, don't hand-roll)** — `cmd/gc/cmd_doctor.go:588` + - shape: `func writeDoctorJSON(w io.Writer, report *doctor.Report) error; type doctorJSONResult struct{Name,Status,Severity,Message,FixHint string; Details []string; ...} (:544); type doctorJSONReport (:556)` + - note: `gc doctor --json` projects CheckResult→doctorJSONResult via doctorStatusString(:566)/doctorSeverityString(:578). DESIGN § 'Slice 1 observability is gc doctor only' — the flags report rides this existing projection; the typed status-wire is deferred to stage 4. +- **deps.env — the single source of truth for bd version anchors (BD_VERSION + matrix cells)** — `deps.env:9` + - shape: `BD_VERSION=v1.1.0 (:9); BD_PREV_VERSION=v1.0.4 (:20 min-supported); BD_CURRENT_VERSION=v1.1.0-rc.1 (:21); BD_CURRENT_REF=<40-char sha> (:22); BD_REPO=gastownhall/beads (:8); DOLT_VERSION=2.1.7 (:7)` + - note: DESIGN graduation: stage1 (Off→Auto) fires when BD_VERSION crosses the CAS floor; stage2 (delete flag) when BD_PREV_VERSION crosses it (DESIGN:1730-1732). These KEYs are what the Spec.VersionAnchor removal predicate compares against. +- **TestBDVersionPins — THE lockstep test a removal/graduation predicate wires into** — `scripts/bd_version_pin_test.go:23` + - shape: `func TestBDVersionPins(t *testing.T) // package scripts_test; reads deps.env via readDotenv (:148), cross-checks every bd anchor` + - note: Existing cross-checks to mirror: bdMinVersion must==BD_PREV_VERSION (:61-65); ready-projection floor must be > init floor (:78-84); bd_compatibility enum members (:88-93); install-bd-archive.sh SHA table per os/arch (:100-112); workflow pins (:118). A new bdConditionalWritesMinVersion anchor gets an analogous assertion block here; the graduation predicate is deps.CompareVersions(env[BD_VERSION], anchor). +- **extractGoStringConst — the helper that reads a Go const anchor for the lockstep** — `scripts/bd_version_pin_test.go:183` + - shape: `func extractGoStringConst(t *testing.T, root, rel, name string) string // regex (?m)^\s*(?:const\s+)?NAME\s*=\s*"([^"]+)"` + - note: How TestBDVersionPins pulls bdMinVersion (init_provider_readiness.go) and bdReadyProjectionMinVersion (bdstore_ready_projection.go) out of source. A CAS removal predicate calls this on the new anchor const, then compares to deps.env. Companion helpers: readDotenv (:148), assertWorkflowPins (:221), scanPinAssignments (:205). +- **bdReadyProjectionMinVersion — EXACT template for the new bdConditionalWritesMinVersion anchor + runtime gate** — `internal/beads/bdstore_ready_projection.go:10` + - shape: `const bdReadyProjectionMinVersion = "1.0.5" // gated at :85 via s.readyProjectionEnabled = deps.CompareVersions(version, bdReadyProjectionMinVersion) >= 0` + - note: DESIGN:1305 says the CAS capability probe switches to ProbeBDVersion + deps.CompareVersions against a new bdConditionalWritesMinVersion 'exactly the bdReadyProjectionMinVersion shape'. Probe-once-per-process + restart-to-re-evaluate pattern at :69-88 is the model (aligns with 'no status files — restart re-probes'). +- **bdMinVersion — init hard-dependency floor const (tracks BD_PREV_VERSION, not BD_VERSION)** — `cmd/gc/init_provider_readiness.go:563` + - shape: `const bdMinVersion = "1.0.4" // consumed in checkHardDependencies deps table minVersion (:604)` + - note: Pinned to BD_PREV_VERSION by TestBDVersionPins:61-65. Precedent that a version anchor is a bare Go string const (no 'v' prefix) cross-checked against deps.env with TrimPrefix('v'). +- **ProbeBDVersion + parseBDVersion — the live bd version probe the CAS gate graduates to** — `internal/beads/binary_versions.go:21` + - shape: `func ProbeBDVersion() (string, error) // runs `bd version`, returns token e.g. "1.0.4"; parseBDVersion at :68; bounded by binaryVersionProbeTimeout=5s (:15)` + - note: bd subprocess execution is confined to internal/beads by boundary_test.go — the CAS capability probe must live here, not in the doctor/API layer. DESIGN:1305: interim detector is `bd update --help` grep for --if-revision; at graduation switch to ProbeBDVersion+deps.CompareVersions. +- **deps.CompareVersions / ParseVersion — semver comparator used by every anchor gate** — `internal/deps/version.go:16` + - shape: `func CompareVersions(a, b string) int // -1/0/1, normalizes 'v' prefix + strips -rc/+build; func ParseVersion(v string) [3]int (:45)` + - note: Pre-release ordering NOT implemented (1.2.3 == 1.2.3-rc.1). This is the comparator both the runtime gate and the deterministic-per-commit graduation test use — no time.Now() in the merge-blocking path (DESIGN:1145). +- **install-bd-archive.sh — the SHA table TestBDVersionPins requires for every installable release** — `.github/scripts/install-bd-archive.sh:60` + - shape: `case entries `vX.Y.Z:linux_amd64) expected_sha="…" ;;` for {linux,darwin}x{amd64,arm64}` + - note: TestBDVersionPins:100-112 fails if BD_PREV_VERSION or BD_VERSION lacks a SHA pin for any of the 4 os/arch tuples. A BD_VERSION bump that graduates the CAS flag must add a matching SHA block here or CI reds. +- **[beads] BeadsConfig.BDCompatibility — sibling field the new conditional_writes lands beside; enum kept in lockstep** — `internal/config/config.go:1377` + - shape: `BDCompatibility string `toml:"bd_compatibility,omitempty" jsonschema:"enum=bd-1.0.4,enum=bd-1.0.5"` // consts BeadsBDCompatibility104/105 at :1393` + - note: DESIGN places `conditional_writes` in this same [beads] section (BeadsConfig) to inherit IsDefined('beads') fragment merge. The jsonschema enum here is cross-checked by TestBDVersionPins:88-93 — a precedent that config-level version tokens are held in lockstep with deps.env floors. +- **DESIGN spec registration & lifecycle wiring for the version tooth (authoritative intent)** — `engdocs/plans/feature-flags/DESIGN.md:444` + - shape: `Spec{ Key:"beads.conditional_writes", VersionAnchor:"bdConditionalWritesMinVersion", Expires:"2027-01-15" } (:441-445); formula_v2 Spec Expires:"2026-12-31", ConfigPath:"daemon.formula_v2" (:455-461)` + - note: VersionAnchor names a Go const that does NOT yet exist in deps.env at registration (untagged beads#4682) — the lifecycle test 'arms itself the day the anchor lands' (DESIGN:375,474). Expires drives a NON-blocking nightly radar + doctor WARN, never a merge-blocking date bomb (DESIGN:374,1732). Graduation stages 1/2 = the TestBDVersionPins family (DESIGN:1730-1731). + + GOTCHAS: + - cmd/gc's okCheck/warnCheck/errorCheck helpers (doctor_v2_checks.go:1246) do NOT set Severity, so results default to SeverityBlocking. An advisory flags-listing must set r.Severity=SeverityAdvisory by hand, or it will gate `gc doctor` exit code (BlockingFailed→exit 1). Conversely a require∧incapable CAS failure MUST be StatusError with Severity left blocking to fail closed (DESIGN:1144). + - Doctor.run type-asserts Renderer and only calls RenderExtras in the streaming path (doctor.go:88-92); RunCollect/--json path (io.Discard) does NOT invoke RenderExtras. Per-store capability tables rendered via Renderer won't appear in `gc doctor --json` — the JSON path must project that data through doctorJSONResult/writeDoctorJSON (cmd_doctor.go:588) instead, or it's invisible to machines. + - TestBDVersionPins lives in package scripts_test (scripts/bd_version_pin_test.go) and there is a DUPLICATE-named repoRoot: repoRoot(t) at scripts/precommit_contract_test.go:123 vs repoRoot() at scripts/add-testenv-import.go:240 (non-test). Any new test file added to internal/beads or a new package needs a generated testenv_import_test.go or the pre-push hook (TestRequiresDedicatedTestenvImportFile) rejects the push — run `go run scripts/add-testenv-import.go`. + - Version anchor consts are bare strings WITHOUT the 'v' prefix (bdMinVersion="1.0.4", bdReadyProjectionMinVersion="1.0.5") while deps.env values ARE v-prefixed (BD_VERSION=v1.1.0). TestBDVersionPins reconciles via strings.TrimPrefix(pref,"v") (:62). A new bdConditionalWritesMinVersion const must follow the un-prefixed convention or deps.CompareVersions comparisons and the lockstep assertion drift. + - The CAS VersionAnchor references a const that does not exist in deps.env yet (untagged beads#4682). The lifecycle/graduation test must be written to 'arm itself the day the anchor lands' — i.e. it cannot hard-require the anchor's presence in deps.env at registration time, or it reds immediately. Model it on the ready-projection floor which gates purely on the in-repo const + a live probe, not on a deps.env row. + - deps.CompareVersions does NOT implement semver pre-release ordering: "1.1.0" and "1.1.0-rc.1" compare EQUAL (version.go:14-15). BD_CURRENT_VERSION=v1.1.0-rc.1 vs BD_VERSION=v1.1.0 therefore tie — a graduation predicate that must distinguish an rc from its release cannot rely on CompareVersions alone. + - Registering a check is only via the `register` closure inside buildDoctorChecks (cmd_doctor.go:189); there is no plugin/auto-discovery for in-tree checks (pack checks are the only data-driven path, cmd_doctor.go:384). Forgetting to add the register() line means the check silently never runs — no compile error. diff --git a/internal/config/compose.go b/internal/config/compose.go index eab29513b6..2b291ee7d4 100644 --- a/internal/config/compose.go +++ b/internal/config/compose.go @@ -1028,7 +1028,16 @@ func mergeFragment(base, fragment *City, fragMeta toml.MetaData, fragPath string // Simple sections: last-writer-wins if fragment defines them. if fragMeta.IsDefined("beads") { + // Preserve a rollout-gate field the fragment did not itself set: a + // fragment defining any [beads] key would otherwise reset the whole + // struct and silently downgrade an explicit conditional_writes opt-in + // (mirror of the daemon.formula_v2 preservation below). Capture before + // the overwrite; a fragment that DOES set conditional_writes still wins. + conditionalWrites := base.Beads.ConditionalWrites base.Beads = fragment.Beads + if !fragMeta.IsDefined("beads", "conditional_writes") { + base.Beads.ConditionalWrites = conditionalWrites + } } if fragMeta.IsDefined("dolt") { base.Dolt = fragment.Dolt diff --git a/internal/config/compose_beads_test.go b/internal/config/compose_beads_test.go new file mode 100644 index 0000000000..c19fe2fd9d --- /dev/null +++ b/internal/config/compose_beads_test.go @@ -0,0 +1,126 @@ +package config + +import ( + "testing" + + "github.com/gastownhall/gascity/internal/fsys" +) + +// TestLoadWithIncludesDefaultsConditionalWrites: omitted → default "off". +func TestLoadWithIncludesDefaultsConditionalWrites(t *testing.T) { + fs := fsys.NewFake() + fs.Files["/city/city.toml"] = []byte(` +[workspace] +name = "test" +`) + cfg, _, err := LoadWithIncludes(fs, "/city/city.toml") + if err != nil { + t.Fatalf("LoadWithIncludes: %v", err) + } + if got := cfg.Beads.NormalizedConditionalWrites(); got != "off" { + t.Fatalf("NormalizedConditionalWrites = %q, want off when omitted", got) + } +} + +// TestLoadWithIncludesPreservesExplicitConditionalWrites: explicit value with no +// fragment survives. +func TestLoadWithIncludesPreservesExplicitConditionalWrites(t *testing.T) { + fs := fsys.NewFake() + fs.Files["/city/city.toml"] = []byte(` +[workspace] +name = "test" + +[beads] +conditional_writes = "require" +`) + cfg, _, err := LoadWithIncludes(fs, "/city/city.toml") + if err != nil { + t.Fatalf("LoadWithIncludes: %v", err) + } + if got := cfg.Beads.NormalizedConditionalWrites(); got != "require" { + t.Fatalf("NormalizedConditionalWrites = %q, want require", got) + } +} + +// TestLoadWithIncludesPreservesConditionalWritesAcrossBeadsFragment is the +// load-bearing regression: an included fragment that defines ONLY an unrelated +// [beads] sibling key must NOT reset the root's explicit conditional_writes. +// Without the per-field IsDefined preservation branch this is a silent +// require→off downgrade through routine config layering. +func TestLoadWithIncludesPreservesConditionalWritesAcrossBeadsFragment(t *testing.T) { + fs := fsys.NewFake() + fs.Files["/city/city.toml"] = []byte(` +include = ["fragment.toml"] + +[workspace] +name = "test" + +[beads] +conditional_writes = "require" +`) + fs.Files["/city/fragment.toml"] = []byte(` +[beads] +bd_compatibility = "bd-1.0.5" +`) + cfg, _, err := LoadWithIncludes(fs, "/city/city.toml") + if err != nil { + t.Fatalf("LoadWithIncludes: %v", err) + } + if got := cfg.Beads.NormalizedConditionalWrites(); got != "require" { + t.Fatalf("NormalizedConditionalWrites = %q, want root require to survive a [beads] fragment", got) + } + if cfg.Beads.NormalizedBDCompatibility() != "bd-1.0.5" { + t.Fatalf("BDCompatibility = %q, want the fragment's bd-1.0.5", cfg.Beads.NormalizedBDCompatibility()) + } +} + +// TestLoadWithIncludesFragmentOverridesConditionalWrites is the companion to the +// preservation test: a fragment that DOES set conditional_writes must win (LWW), +// so the preservation branch can't drift into "base value always wins." +func TestLoadWithIncludesFragmentOverridesConditionalWrites(t *testing.T) { + fs := fsys.NewFake() + fs.Files["/city/city.toml"] = []byte(` +include = ["fragment.toml"] + +[workspace] +name = "test" + +[beads] +conditional_writes = "off" +`) + fs.Files["/city/fragment.toml"] = []byte(` +[beads] +conditional_writes = "auto" +`) + cfg, _, err := LoadWithIncludes(fs, "/city/city.toml") + if err != nil { + t.Fatalf("LoadWithIncludes: %v", err) + } + if got := cfg.Beads.NormalizedConditionalWrites(); got != "auto" { + t.Fatalf("NormalizedConditionalWrites = %q, want the fragment's auto to win", got) + } +} + +// TestConditionalWritesParseAndDefault covers decode and the accessor default. +func TestConditionalWritesParseAndDefault(t *testing.T) { + // zero value / omitted → default "off". + if (BeadsConfig{}).NormalizedConditionalWrites() != "off" { + t.Fatalf("zero-value accessor = %q, want off", (BeadsConfig{}).NormalizedConditionalWrites()) + } + // an explicit value decodes. + out, err := Parse([]byte("[beads]\nconditional_writes = \"auto\"\n")) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if out.Beads.ConditionalWrites != "auto" { + t.Fatalf("decoded conditional_writes = %q, want auto", out.Beads.ConditionalWrites) + } + // a [beads] section without the key leaves it empty (→ default via accessor). + out2, err := Parse([]byte("[beads]\nprovider = \"bd\"\n")) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if out2.Beads.ConditionalWrites != "" || out2.Beads.NormalizedConditionalWrites() != "off" { + t.Fatalf("unset conditional_writes = %q (norm %q), want empty→off", out2.Beads.ConditionalWrites, out2.Beads.NormalizedConditionalWrites()) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index e1338a2f2e..ad0533a54c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1375,6 +1375,13 @@ type BeadsConfig struct { // and avoids bd ready/list flags that are unavailable or incomplete in bd // 1.0.4. BDCompatibility string `toml:"bd_compatibility,omitempty" jsonschema:"enum=bd-1.0.4,enum=bd-1.0.5"` + // ConditionalWrites selects the bead-write discipline (the rollout gate keyed + // "beads.conditional_writes"; see internal/rollout): "off" (legacy, + // byte-identical), "auto" (compare-and-swap where the store is capable, loud + // degrade otherwise), or "require" (CAS or a typed refusal). Empty defaults to + // "off". The string is validated and mapped to a rollout.Mode in + // internal/rollout — never here, which would be an import cycle. + ConditionalWrites string `toml:"conditional_writes,omitempty" jsonschema:"enum=off,enum=auto,enum=require"` // Policies defines per-bead-use storage and garbage-collection defaults. // Policy names are interpreted by higher-level systems; unknown names are // preserved so packs can stage future policy classes without breaking load. @@ -1409,6 +1416,20 @@ func (b BeadsConfig) NormalizedBDCompatibility() string { } } +// NormalizedConditionalWrites returns the configured conditional-writes value, +// mapping ONLY the empty string to the built-in default "off". Unlike +// NormalizedBDCompatibility, an unknown non-empty value passes through verbatim +// rather than collapsing to the default: it is rejected upstream (by +// internal/rollout on resolve), because a typo must never silently mean "off". +// The string→rollout.Mode mapping deliberately lives in internal/rollout to keep +// config free of a rollout import (cycle). +func (b BeadsConfig) NormalizedConditionalWrites() string { + if b.ConditionalWrites == "" { + return "off" + } + return b.ConditionalWrites +} + // UsesBD105CLISemantics reports whether bd-backed code may rely on bd 1.0.5 // command-line behavior. func (b BeadsConfig) UsesBD105CLISemantics() bool { diff --git a/internal/rollout/boundary_test.go b/internal/rollout/boundary_test.go new file mode 100644 index 0000000000..f9986d10d1 --- /dev/null +++ b/internal/rollout/boundary_test.go @@ -0,0 +1,64 @@ +package rollout + +import ( + "go/parser" + "go/token" + "os" + "strings" + "testing" +) + +// TestRolloutImportBoundary is the structural half of the general-Auto guarantee +// and the "no capability flags" line: internal/rollout non-test files may import +// ONLY the standard library, internal/config, and internal/deps. Importing any +// consumer package — internal/beads above all, but also beads-adjacent +// internal/beadmeta, internal/dispatch, internal/molecule, internal/events — +// fails this test naming the file and package. The capability model is general; +// beads CAS is merely its first consumer and lives on the OTHER side of this line. +func TestRolloutImportBoundary(t *testing.T) { + t.Parallel() + const self = "github.com/gastownhall/gascity/internal/rollout" + allowedInternal := map[string]bool{ + "github.com/gastownhall/gascity/internal/config": true, + "github.com/gastownhall/gascity/internal/deps": true, + } + + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("read package dir: %v", err) + } + fset := token.NewFileSet() + checked := 0 + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + f, err := parser.ParseFile(fset, name, nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + checked++ + for _, imp := range f.Imports { + p := strings.Trim(imp.Path.Value, `"`) + if isStdlibImport(p) || allowedInternal[p] || p == self { + continue + } + t.Errorf("%s imports disallowed package %q; internal/rollout must import only "+ + "stdlib + internal/config + internal/deps (it must never reach a consumer like internal/beads)", name, p) + } + } + if checked == 0 { + t.Fatal("import-boundary test scanned zero non-test package files") + } +} + +// isStdlibImport reports whether an import path is a standard-library package: +// its first path segment carries no dot, i.e. no module domain. +func isStdlibImport(p string) bool { + seg := p + if i := strings.IndexByte(p, '/'); i >= 0 { + seg = p[:i] + } + return !strings.Contains(seg, ".") +} diff --git a/internal/rollout/capability.go b/internal/rollout/capability.go new file mode 100644 index 0000000000..bb5f76d33e --- /dev/null +++ b/internal/rollout/capability.go @@ -0,0 +1,67 @@ +package rollout + +import "context" + +// Capability reports whether the runtime can execute a gate's new path. It is +// supplied per-call by a consumer-owned adapter (beads CAS supplies a bd/store +// probe; a future non-beads gate supplies its own) and is NEVER stored on a Spec +// or in the registry — that is what keeps this package free of consumer imports +// and the capability model general. A nil Capability means "this gate has no +// runtime capability question" and is vacuously capable. +type Capability func(ctx context.Context) (capable bool, reason string) + +// Decision is the four-way verdict of the enable-AND-capable product. +type Decision string + +const ( + // UseLegacy runs the old path (Off, or ModeUnset defaulted to Off). + UseLegacy Decision = "use_legacy" + // UseNew runs the new path (Auto or Require, and capable). + UseNew Decision = "use_new" + // DegradeLoud runs the old path but obliges the caller to surface a + // diagnostic (Auto and not capable) — never a silent fallback. + DegradeLoud Decision = "degrade_loud" + // RefuseClosed is a typed refusal that must not fall back to the old path + // (Require and not capable). + RefuseClosed Decision = "refuse_closed" +) + +// ResolveCapability computes the enable-AND-capable product — here and nowhere +// else, for every rollout gate, generically. The cell contract: +// +// ModeUnset -> UseLegacy ("mode unset; defaulted to off"); cap not consulted +// Off -> UseLegacy ("mode off"); cap NOT consulted (Off is zero-cost) +// Auto, capable -> UseNew +// Auto, !capable -> DegradeLoud (reason carries the predicate's reason) +// Require, capable -> UseNew +// Require, !capable -> RefuseClosed (reason carries the predicate's reason) +// +// A nil cap is vacuously capable, so Auto/Require with a nil predicate resolve to +// UseNew. The capability predicate's reason string propagates verbatim into the +// returned reason. +func ResolveCapability(ctx context.Context, mode Mode, pred Capability) (Decision, string) { + switch mode { + case ModeUnset: + return UseLegacy, "mode unset; defaulted to off" + case Off: + return UseLegacy, "mode off" + case Auto, Require: + // fall through to the capability check below. + default: + // An unrecognized mode is treated as the safe legacy path; Resolve + // rejects out-of-enum config before a value ever reaches here. + return UseLegacy, "unrecognized mode " + string(mode) + "; defaulted to off" + } + + capable, reason := true, "no capability predicate" + if pred != nil { + capable, reason = pred(ctx) + } + if capable { + return UseNew, reason + } + if mode == Require { + return RefuseClosed, reason + } + return DegradeLoud, reason +} diff --git a/internal/rollout/capability_test.go b/internal/rollout/capability_test.go new file mode 100644 index 0000000000..f09ee99f7b --- /dev/null +++ b/internal/rollout/capability_test.go @@ -0,0 +1,72 @@ +package rollout + +import ( + "context" + "strings" + "testing" +) + +// TestResolveCapabilityGeneral is the general-Auto acceptance artifact: a +// SYNTHETIC, non-beads capability predicate (a fake "runtime provider supports +// nudge" probe) drives every cell of the resolver using ONLY rollout types. It +// is the mechanically-checkable proof that capability-resolution is general and +// not beads-locked. (The import-boundary test guarantees this package cannot +// even reach internal/beads.) +func TestResolveCapabilityGeneral(t *testing.T) { + t.Parallel() + ctx := context.Background() + + capable := func(reason string) Capability { + return func(context.Context) (bool, string) { return true, reason } + } + incapable := func(reason string) Capability { + return func(context.Context) (bool, string) { return false, reason } + } + + cases := []struct { + name string + mode Mode + cap Capability + wantDec Decision + wantReason string + }{ + {"unset defaults legacy", ModeUnset, incapable("unconsulted"), UseLegacy, "mode unset"}, + {"off legacy", Off, incapable("unconsulted"), UseLegacy, "mode off"}, + {"auto capable", Auto, capable("provider supports nudge"), UseNew, "provider supports nudge"}, + {"auto incapable degrades loud", Auto, incapable("provider lacks nudge"), DegradeLoud, "provider lacks nudge"}, + {"require capable", Require, capable("provider supports nudge"), UseNew, "provider supports nudge"}, + {"require incapable refuses closed", Require, incapable("provider lacks nudge"), RefuseClosed, "provider lacks nudge"}, + {"auto nil predicate vacuously capable", Auto, nil, UseNew, "no capability predicate"}, + {"require nil predicate vacuously capable", Require, nil, UseNew, "no capability predicate"}, + {"unrecognized mode fails closed to legacy", Mode("bananas"), incapable("unconsulted"), UseLegacy, "unrecognized mode"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + dec, reason := ResolveCapability(ctx, tc.mode, tc.cap) + if dec != tc.wantDec { + t.Errorf("decision = %q, want %q", dec, tc.wantDec) + } + if !strings.Contains(reason, tc.wantReason) { + t.Errorf("reason = %q, want to contain %q", reason, tc.wantReason) + } + }) + } +} + +// TestResolveCapabilityOffIsZeroCost proves Off and ModeUnset never consult the +// capability predicate — the legacy path pays nothing. +func TestResolveCapabilityOffIsZeroCost(t *testing.T) { + t.Parallel() + for _, mode := range []Mode{Off, ModeUnset} { + called := false + probe := Capability(func(context.Context) (bool, string) { called = true; return true, "x" }) + if dec, _ := ResolveCapability(context.Background(), mode, probe); dec != UseLegacy { + t.Errorf("mode %q: decision = %q, want use_legacy", mode, dec) + } + if called { + t.Errorf("mode %q consulted the capability predicate; must be zero-cost", mode) + } + } +} diff --git a/internal/rollout/doc.go b/internal/rollout/doc.go new file mode 100644 index 0000000000..989d2dd5a5 --- /dev/null +++ b/internal/rollout/doc.go @@ -0,0 +1,19 @@ +// Package rollout is gascity's rollout-gate (feature-flag) subsystem: a typed +// registry of infrastructure rollout/migration gates plus a general +// capability-resolution model that selects between two mechanical code paths. +// +// A rollout gate is NOT an agent-capability flag. It gates internal transport +// paths (which store CAS verb to call, which migration branch to run) that are +// invisible to prompts and cannot express per-agent behavior — the design keeps +// the "no capability flags" exclusion intact (see engdocs/plans/feature-flags). +// +// The package is deliberately narrow in its dependencies: it imports only the +// standard library and internal/config (and, reserved, internal/deps). It must +// NEVER import internal/beads or any consumer package — the capability model is +// general and beads CAS is merely its first consumer. The allowlist is enforced +// by TestRolloutImportBoundary. +// +// The package holds no process-level mutable state and reads no environment at +// init: a Flags value is computed once from merged config plus env overrides via +// Resolve, then threaded by value. Tests build isolated Flags with ForTest. +package rollout diff --git a/internal/rollout/flag_beads_conditional_writes.go b/internal/rollout/flag_beads_conditional_writes.go new file mode 100644 index 0000000000..cf98d96451 --- /dev/null +++ b/internal/rollout/flag_beads_conditional_writes.go @@ -0,0 +1,33 @@ +package rollout + +import "github.com/gastownhall/gascity/internal/config" + +// keyBeadsConditionalWrites is the registry Key for the beads CAS rollout gate. +const keyBeadsConditionalWrites = "beads.conditional_writes" + +// envBeadsConditionalWrites is the single source of truth for this gate's env +// override name: the registry Spec.EnvOverride, the resolver, and the +// testenv.LeakVectorVars membership test all reference it, so the three can +// never drift into a silent break-glass no-op. +const envBeadsConditionalWrites = "GC_BEADS_CONDITIONAL_WRITES" + +// BeadsConditionalWrites returns the resolved beads.conditional_writes mode. +func (f Flags) BeadsConditionalWrites() Mode { + return f.beadsConditionalWrites.value +} + +// WithBeadsConditionalWrites overrides beads.conditional_writes on a ForTest +// Flags value. +func WithBeadsConditionalWrites(m Mode) ForTestOption { + return func(b *flagsBuilder) { + b.flags.beadsConditionalWrites = resolved[Mode]{value: m, origin: OriginConfig} + } +} + +// readBeadsConditionalWrites returns the raw config spelling for the gate and +// whether the merged config set it (empty string = unset, since the field is +// omitempty). +func readBeadsConditionalWrites(cfg *config.City) (raw string, defined bool) { + raw = cfg.Beads.ConditionalWrites + return raw, raw != "" +} diff --git a/internal/rollout/flag_daemon_formula_v2.go b/internal/rollout/flag_daemon_formula_v2.go new file mode 100644 index 0000000000..a065b766c2 --- /dev/null +++ b/internal/rollout/flag_daemon_formula_v2.go @@ -0,0 +1,28 @@ +package rollout + +import "github.com/gastownhall/gascity/internal/config" + +// keyDaemonFormulaV2 is the registry Key for the formula_v2 migration gate. +const keyDaemonFormulaV2 = "daemon.formula_v2" + +// FormulaV2 returns the resolved daemon.formula_v2 value (the kill-switch for the +// legacy formula v1 path; default true). +func (f Flags) FormulaV2() bool { + return f.formulaV2.value +} + +// WithFormulaV2 overrides daemon.formula_v2 on a ForTest Flags value. +func WithFormulaV2(enabled bool) ForTestOption { + return func(b *flagsBuilder) { + b.flags.formulaV2 = resolved[bool]{value: enabled, origin: OriginConfig} + } +} + +// readDaemonFormulaV2 reads cfg.Daemon.FormulaV2; a nil pointer means unset (the +// built-in default, true). +func readDaemonFormulaV2(cfg *config.City) (value bool, defined bool) { + if cfg.Daemon.FormulaV2 == nil { + return true, false + } + return *cfg.Daemon.FormulaV2, true +} diff --git a/internal/rollout/flags.go b/internal/rollout/flags.go new file mode 100644 index 0000000000..fe20b372a3 --- /dev/null +++ b/internal/rollout/flags.go @@ -0,0 +1,47 @@ +package rollout + +// resolved pairs a gate's effective value with the layer that produced it. +type resolved[T any] struct { + value T + origin Origin +} + +// Flags is the immutable per-process snapshot of every registered rollout gate. +// It is a value type: copy it and thread it by dependency injection; never point +// at it from package-level state. +// +// The zero value is DEGRADED-SAFE, not the builtin defaults: a never-Resolved +// Flags reads each gate's Go zero — BeadsConditionalWrites() returns ModeUnset +// (which ResolveCapability maps to the legacy path with a visible diagnostic), +// and FormulaV2() returns false (the legacy v1 path, NOT the builtin default +// true). So an unwired Flags runs legacy paths; OriginOf returns "" for a gate a +// zero Flags never resolved. Build defaults with ForTest or Resolve, never Flags{}. +type Flags struct { + beadsConditionalWrites resolved[Mode] + formulaV2 resolved[bool] + notices []Notice +} + +// OriginOf returns the Origin recorded for a registered gate Key (empty for an +// unknown key). For doctor/status rendering only — production reads use the +// typed accessors. +func (f Flags) OriginOf(key string) Origin { + switch key { + case keyBeadsConditionalWrites: + return f.beadsConditionalWrites.origin + case keyDaemonFormulaV2: + return f.formulaV2.origin + default: + return "" + } +} + +// Notices returns the resolution notices retained for the process lifetime. +func (f Flags) Notices() []Notice { + if len(f.notices) == 0 { + return nil + } + out := make([]Notice, len(f.notices)) + copy(out, f.notices) + return out +} diff --git a/internal/rollout/flags_test.go b/internal/rollout/flags_test.go new file mode 100644 index 0000000000..7ca108d4e1 --- /dev/null +++ b/internal/rollout/flags_test.go @@ -0,0 +1,38 @@ +package rollout + +import "testing" + +// TestNoticesReturnsDefensiveCopy proves a caller cannot mutate a Flags' retained +// notices through the slice Notices() returns. +func TestNoticesReturnsDefensiveCopy(t *testing.T) { + t.Parallel() + f, err := Resolve(cityWith("require", nil), + ResolveOptions{LookupEnv: envMap(map[string]string{envBeadsConditionalWrites: "auto"})}) + if err != nil { + t.Fatal(err) + } + n1 := f.Notices() + if len(n1) == 0 { + t.Fatal("expected at least one notice (env overrides config)") + } + n1[0].Message = "MUTATED" + if f.Notices()[0].Message == "MUTATED" { + t.Error("Notices() must return a defensive copy; a caller's mutation leaked into the Flags") + } +} + +// TestZeroFlagsIsLegacy pins the documented degraded-safe zero value: an unwired +// Flags{} runs legacy paths (not the builtin defaults) and reports no origin. +func TestZeroFlagsIsLegacy(t *testing.T) { + t.Parallel() + var z Flags + if z.BeadsConditionalWrites() != ModeUnset { + t.Errorf("zero beads = %q, want ModeUnset", z.BeadsConditionalWrites()) + } + if z.FormulaV2() { + t.Errorf("zero formula_v2 = true, want false (legacy path, not the builtin default true)") + } + if z.OriginOf(keyBeadsConditionalWrites) != "" { + t.Errorf("zero OriginOf = %q, want empty (unwired)", z.OriginOf(keyBeadsConditionalWrites)) + } +} diff --git a/internal/rollout/fortest.go b/internal/rollout/fortest.go new file mode 100644 index 0000000000..ce84e8bc45 --- /dev/null +++ b/internal/rollout/fortest.go @@ -0,0 +1,33 @@ +package rollout + +// ForTestOption sets one gate on a Flags value under construction. There is +// exactly one With* constructor per registered gate, declared in that gate's +// file, so deleting a gate breaks its callers at COMPILE time. +type ForTestOption func(*flagsBuilder) + +// flagsBuilder is the mutable, call-local Flags under construction — never +// package state. +type flagsBuilder struct { + flags Flags +} + +// defaultFlags is the single source of built-in defaults, shared by Resolve and +// ForTest. registry_test pins these values equal to the Spec.Default entries and +// to the config-accessor defaults, so the three homes cannot drift. +func defaultFlags() Flags { + return Flags{ + beadsConditionalWrites: resolved[Mode]{value: Off, origin: OriginBuiltin}, + formulaV2: resolved[bool]{value: true, origin: OriginBuiltin}, + } +} + +// ForTest builds an immutable Flags from the built-in defaults plus typed +// overrides. It reads neither config nor env, holds no process-scoped state, and +// is safe under t.Parallel by construction (each call returns its own value). +func ForTest(opts ...ForTestOption) Flags { + b := &flagsBuilder{flags: defaultFlags()} + for _, o := range opts { + o(b) + } + return b.flags +} diff --git a/internal/rollout/fortest_test.go b/internal/rollout/fortest_test.go new file mode 100644 index 0000000000..2a4fb3776c --- /dev/null +++ b/internal/rollout/fortest_test.go @@ -0,0 +1,40 @@ +package rollout + +import "testing" + +// TestForTestDefaults proves ForTest with no options yields every gate's +// built-in default. +func TestForTestDefaults(t *testing.T) { + t.Parallel() + f := ForTest() + if f.BeadsConditionalWrites() != Off { + t.Errorf("default beads = %q, want off", f.BeadsConditionalWrites()) + } + if !f.FormulaV2() { + t.Errorf("default formula_v2 = false, want true") + } +} + +// TestForTestIsolationRequire and ...Off run in parallel with OPPOSITE overrides: +// if the seam held any process-scoped mutable state, one would observe the +// other's value. Repeated reads widen the interleave window. Passing under +// -race proves per-instance isolation. +func TestForTestIsolationRequire(t *testing.T) { + t.Parallel() + f := ForTest(WithBeadsConditionalWrites(Require), WithFormulaV2(false)) + for i := 0; i < 2000; i++ { + if f.BeadsConditionalWrites() != Require || f.FormulaV2() { + t.Fatalf("iter %d: got %q/%v, want require/false — cross-test leakage", i, f.BeadsConditionalWrites(), f.FormulaV2()) + } + } +} + +func TestForTestIsolationOff(t *testing.T) { + t.Parallel() + f := ForTest(WithBeadsConditionalWrites(Off), WithFormulaV2(true)) + for i := 0; i < 2000; i++ { + if f.BeadsConditionalWrites() != Off || !f.FormulaV2() { + t.Fatalf("iter %d: got %q/%v, want off/true — cross-test leakage", i, f.BeadsConditionalWrites(), f.FormulaV2()) + } + } +} diff --git a/internal/rollout/mode.go b/internal/rollout/mode.go new file mode 100644 index 0000000000..7eb96b71c7 --- /dev/null +++ b/internal/rollout/mode.go @@ -0,0 +1,49 @@ +package rollout + +import ( + "fmt" + "strings" +) + +// Mode is the tri-state value kind for a correctness/migration rollout gate. +type Mode string + +const ( + // ModeUnset is the zero value: "nobody threaded a mode." It resolves AS Off + // but carries a diagnostic reason so an unwired call site is visible rather + // than silently defaulting. + ModeUnset Mode = "" + // Off runs the legacy path, byte-identical to pre-flag behavior. Off is + // zero-cost: a capability predicate is never consulted. + Off Mode = "off" + // Auto runs the new path where the runtime is capable and loud-degrades to + // the legacy path otherwise — never a silent unconditional fallback. + Auto Mode = "auto" + // Require runs the new path or refuses closed; a silent fallback is + // inexpressible. + Require Mode = "require" +) + +// ParseMode parses a user-supplied spelling into a Mode. It is case- and +// space-tolerant ("Require", " AUTO " are accepted) and recognizes ONLY the +// three mode names — bool/truthy spellings and the empty string are errors that +// name the off|auto|require grammar. (A tri-state gate has no meaningful bool +// spelling; ModeUnset is produced by absence, never by parsing a value.) +func ParseMode(s string) (Mode, error) { + switch normalizeToken(s) { + case "off": + return Off, nil + case "auto": + return Auto, nil + case "require": + return Require, nil + default: + return ModeUnset, fmt.Errorf("invalid mode %q: want one of off, auto, require", s) + } +} + +// normalizeToken lowercases and trims surrounding whitespace for the mode +// grammar (case/space tolerant break-glass values). +func normalizeToken(s string) string { + return strings.ToLower(strings.TrimSpace(s)) +} diff --git a/internal/rollout/notice.go b/internal/rollout/notice.go new file mode 100644 index 0000000000..fd72a9e53c --- /dev/null +++ b/internal/rollout/notice.go @@ -0,0 +1,45 @@ +package rollout + +// Origin names the precedence layer that produced a resolved value. +type Origin string + +const ( + // OriginBuiltin means the gate was absent everywhere; Spec.Default was used. + OriginBuiltin Origin = "builtin" + // OriginConfig means the value came from merged config; env unset/inapplicable. + OriginConfig Origin = "config" + // OriginEnv means an env override produced the value. + OriginEnv Origin = "env" +) + +// NoticeKind names a typed resolution/lifecycle fact worth surfacing. +type NoticeKind string + +const ( + // NoticeEnvOverrideActive records that a valid env value was applied while + // config was silent — informational. + NoticeEnvOverrideActive NoticeKind = "env_override_active" + // NoticeEnvOverridesConfig records that a valid env value CONTRADICTS an + // explicit config value — surfaced loudly so an operator's break-glass is not + // mistaken for the durable config. + NoticeEnvOverridesConfig NoticeKind = "env_overrides_config" + // NoticeInvalidEnvIgnored records that a malformed env value was ignored and + // the config-resolved value kept (warn-and-use-config; never refuse-to-start). + NoticeInvalidEnvIgnored NoticeKind = "invalid_env_ignored" + // NoticePendingRestart records that the on-disk config diverged from the + // boot-latched value. The type ships now; the reload wiring that emits it + // lands with the composition-root wiring (PR-1c). + NoticePendingRestart NoticeKind = "pending_restart" +) + +// Notice is one typed, structured resolution fact. Notices are retained ON the +// Flags value for the process lifetime and rendered by doctor/status later — +// never a dropped stderr line. +type Notice struct { + Kind NoticeKind + FlagKey string // Spec.Key + EnvVar string // Spec.EnvOverride when env-related, else "" + ConfigValue string // raw config spelling ("" = unset) + EnvValue string // raw env spelling as found + Message string // human line, always carrying the gate and the outcome +} diff --git a/internal/rollout/registry.go b/internal/rollout/registry.go new file mode 100644 index 0000000000..ce39982ef9 --- /dev/null +++ b/internal/rollout/registry.go @@ -0,0 +1,79 @@ +package rollout + +// This file is the canonical rollout-gate registry. It is CODEOWNERS-gated: a +// human owner reviews every Spec addition, Expires extension, and Category +// classification. +// +// The litmus for adding a gate here (all must hold, else it does not belong): +// 1. It selects between two MECHANICAL code paths (SelectsBetween), not agent +// behavior — nothing a prompt could express, nothing a smarter model obviates. +// 2. It is terminal: a rollout/migration gate names when it dies (Expires + +// VersionAnchor). Only a killswitch is long-lived. +// 3. Its value lives in its owning config section, read through internal/config; +// this package never imports the consumer. + +// ptr returns a pointer to v — the local literal helper for Default arms. +func ptr[T any](v T) *T { return &v } + +// specs is the canonical registry. It is unexported so no test can append a +// phantom Spec that leaks into a sibling's ForTest defaults. +var specs = []Spec{ + { + Key: keyBeadsConditionalWrites, + Category: InfraRollout, + ConfigPath: "beads.conditional_writes", + EnvOverride: envBeadsConditionalWrites, + EnvSemantics: EnvOverrides, + Default: Default{Mode: ptr(Off)}, + Owner: Owner{Bead: "ga-1ypn4t", GitHub: "@gastownhall/gascity-admin"}, + Expires: "2027-01-15", + VersionAnchor: "BD_CONDITIONAL_WRITES_MIN_VERSION", + SelectsBetween: [2]string{"unconditional bd write", "revision-guarded CAS write (bd --if-revision / UpdateIssueIfMatch)"}, + Justification: "Adopt beads whole-row compare-and-swap so gc.control_epoch and " + + "gc.drain.reserved_by writes fail a lost race instead of silently clobbering a " + + "concurrent peer; gated for mixed-fleet rollout while beads#4682 is untagged.", + }, + { + Key: keyDaemonFormulaV2, + Category: InfraMigration, + ConfigPath: "daemon.formula_v2", + EnvOverride: "", + Default: Default{Bool: ptr(true)}, + Owner: Owner{Bead: "ga-rdva30", GitHub: "@gastownhall/gascity-admin"}, + Expires: "2026-12-31", + VersionAnchor: "gcFormulaV2RemovalFloor", + SelectsBetween: [2]string{"formula v1 (legacy global-setter path)", "formula v2 (graph workflow path)"}, + Justification: "Retire the v1 formula path and its process-global atomic.Bool setter " + + "anti-pattern; the migration whose completion deletes cmd/gc/feature_flags.go.", + }, +} + +// Specs returns a defensive copy of the canonical registry. The Default pointers +// are deep-copied too, so a caller mutating a returned Spec's Default cannot +// reach through into the canonical registry. +func Specs() []Spec { + out := make([]Spec, len(specs)) + copy(out, specs) + for i := range out { + if m := out[i].Default.Mode; m != nil { + out[i].Default.Mode = ptr(*m) + } + if b := out[i].Default.Bool; b != nil { + out[i].Default.Bool = ptr(*b) + } + } + return out +} + +// specByKey returns the canonical Spec for key (zero Spec if unregistered). It +// reads the package-private slice directly (no defensive copy needed for an +// internal, read-only lookup) so the resolver can source names/semantics from +// the registry. +func specByKey(key string) Spec { + for _, s := range specs { + if s.Key == key { + return s + } + } + return Spec{} +} diff --git a/internal/rollout/registry_binding_test.go b/internal/rollout/registry_binding_test.go new file mode 100644 index 0000000000..34f5e168de --- /dev/null +++ b/internal/rollout/registry_binding_test.go @@ -0,0 +1,190 @@ +package rollout + +import ( + "bufio" + "os" + "reflect" + "sort" + "strings" + "testing" + + "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/testenv" +) + +// TestResolveConsultsExactlyRegisteredEnvVars pins the env var NAMES Resolve +// reads to the registry's Spec.EnvOverride set: nothing undeclared is consulted, +// and every declared name is consulted. This kills the "rename Spec.EnvOverride, +// break-glass silently no-ops" drift — the registry becomes the source of truth +// the resolver actually obeys. +func TestResolveConsultsExactlyRegisteredEnvVars(t *testing.T) { + t.Parallel() + var consulted []string + rec := func(k string) (string, bool) { consulted = append(consulted, k); return "", false } + if _, err := Resolve(&config.City{}, ResolveOptions{LookupEnv: rec}); err != nil { + t.Fatalf("Resolve: %v", err) + } + want := map[string]bool{} + for _, s := range Specs() { + if s.EnvOverride != "" { + want[s.EnvOverride] = true + } + } + got := map[string]bool{} + for _, k := range consulted { + got[k] = true + } + if !reflect.DeepEqual(got, want) { + t.Errorf("Resolve consulted env vars %v, want exactly the registered Spec.EnvOverride set %v", sortedKeys(got), sortedKeys(want)) + } +} + +// TestConfigPathAddressesTheFieldResolveReads sets the config field named by each +// Spec.ConfigPath (via reflection) to a valid non-default value and asserts the +// gate resolves as config-origin. If ConfigPath is repointed away from the field +// Resolve actually reads, the gate stays builtin and this fails. +func TestConfigPathAddressesTheFieldResolveReads(t *testing.T) { + t.Parallel() + for _, s := range Specs() { + s := s + t.Run(s.Key, func(t *testing.T) { + t.Parallel() + cfg := &config.City{} + setConfigFieldNonDefault(t, cfg, s.ConfigPath, s.Default) + f, err := Resolve(cfg, ResolveOptions{LookupEnv: func(string) (string, bool) { return "", false }}) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if f.OriginOf(s.Key) != OriginConfig { + t.Errorf("%s: set the field at ConfigPath %q to a non-default value but the gate origin is %q, not config — "+ + "ConfigPath does not address the field Resolve reads", s.Key, s.ConfigPath, f.OriginOf(s.Key)) + } + }) + } +} + +// TestEnvOverridesAreLeakVectors: every registered env override must be scrubbed +// by testenv so a live shell export cannot leak into a test and flip a gate. +func TestEnvOverridesAreLeakVectors(t *testing.T) { + t.Parallel() + leak := map[string]bool{} + for _, v := range testenv.LeakVectorVars { + leak[v] = true + } + for _, s := range Specs() { + if s.EnvOverride != "" && !leak[s.EnvOverride] { + t.Errorf("%s: EnvOverride %q is not in testenv.LeakVectorVars; a stray shell value could flip it during tests", s.Key, s.EnvOverride) + } + } +} + +// TestBeadsVersionAnchorPending documents the CAS gate's "pending" anchor state: +// VersionAnchor names a deps.env key that is currently ABSENT (untagged +// beads#4682), which is legal — distinct from an empty VersionAnchor (a +// validation failure). When the key lands, this test flips and prompts wiring the +// graduation tooth. +func TestBeadsVersionAnchorPending(t *testing.T) { + t.Parallel() + s := specByKey(keyBeadsConditionalWrites) + if s.VersionAnchor != "BD_CONDITIONAL_WRITES_MIN_VERSION" { + t.Fatalf("beads VersionAnchor = %q, want BD_CONDITIONAL_WRITES_MIN_VERSION", s.VersionAnchor) + } + present, err := depsEnvHasKey("../../deps.env", s.VersionAnchor) + if err != nil { + t.Skipf("deps.env not readable from the package dir: %v", err) + } + if present { + t.Errorf("%s is now present in deps.env — the CAS gate has graduated past pending; wire the graduation/removal tooth", s.VersionAnchor) + } +} + +// --- reflection helpers (test-only) --- + +func setConfigFieldNonDefault(t *testing.T, cfg *config.City, path string, def Default) { + t.Helper() + v := reflect.ValueOf(cfg).Elem() + segs := strings.Split(path, ".") + for i, seg := range segs { + for v.Kind() == reflect.Pointer { + if v.IsNil() { + v.Set(reflect.New(v.Type().Elem())) + } + v = v.Elem() + } + f, ok := valueFieldByTOMLName(v, seg) + if !ok { + t.Fatalf("ConfigPath %q: no field with toml tag %q", path, seg) + } + if i == len(segs)-1 { + setNonDefault(t, f, def) + return + } + v = f + } +} + +func valueFieldByTOMLName(v reflect.Value, name string) (reflect.Value, bool) { + tt := v.Type() + for i := 0; i < tt.NumField(); i++ { + tag := tt.Field(i).Tag.Get("toml") + if before, _, _ := strings.Cut(tag, ","); before == name { + return v.Field(i), true + } + } + return reflect.Value{}, false +} + +// setNonDefault sets f to a valid value that differs from the gate's built-in +// default: a distinct valid mode for a string (Mode) gate, or !default for a +// bool gate. +func setNonDefault(t *testing.T, f reflect.Value, def Default) { + t.Helper() + switch { + case def.Mode != nil: + for _, m := range []Mode{Require, Auto, Off} { + if m != *def.Mode { + f.SetString(string(m)) + return + } + } + case def.Bool != nil: + want := !*def.Bool + if f.Kind() == reflect.Pointer { + np := reflect.New(f.Type().Elem()) + np.Elem().SetBool(want) + f.Set(np) + } else { + f.SetBool(want) + } + default: + t.Fatalf("Default sets no arm") + } +} + +func depsEnvHasKey(path, key string) (bool, error) { + data, err := os.Open(path) + if err != nil { + return false, err + } + defer func() { _ = data.Close() }() + sc := bufio.NewScanner(data) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if k, _, ok := strings.Cut(line, "="); ok && strings.TrimSpace(k) == key { + return true, nil + } + } + return false, sc.Err() +} + +func sortedKeys(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/internal/rollout/registry_test.go b/internal/rollout/registry_test.go new file mode 100644 index 0000000000..61bb40f7c9 --- /dev/null +++ b/internal/rollout/registry_test.go @@ -0,0 +1,182 @@ +package rollout + +import ( + "reflect" + "strings" + "testing" + + "github.com/gastownhall/gascity/internal/config" +) + +// hasErr reports whether any error contains substr — so a masking sibling rule +// cannot satisfy an assertion meant for a specific rule. +func hasErr(errs []error, substr string) bool { + for _, e := range errs { + if strings.Contains(e.Error(), substr) { + return true + } + } + return false +} + +// TestCanonicalRegistryValid proves the shipped registry passes every structural +// rule (Category, one-Default-arm, reflection-verified ConfigPath, env hygiene, +// Owner, per-category lifecycle anchors, SelectsBetween, Justification). +func TestCanonicalRegistryValid(t *testing.T) { + t.Parallel() + for _, e := range ValidateSpecs(Specs()) { + t.Errorf("canonical registry violation: %v", e) + } +} + +// TestSpecIsPureData proves Spec (transitively) has no func-kind field, so a +// capability predicate can never be stored on the registry and registry.go stays +// CODEOWNERS-reviewable data. +func TestSpecIsPureData(t *testing.T) { + t.Parallel() + assertNoFuncFields(t, reflect.TypeOf(Spec{}), "Spec") +} + +func assertNoFuncFields(t *testing.T, ty reflect.Type, path string) { + t.Helper() + switch ty.Kind() { + case reflect.Func: + t.Errorf("%s is a func-kind field; Spec must be pure data", path) + case reflect.Struct: + for i := 0; i < ty.NumField(); i++ { + f := ty.Field(i) + assertNoFuncFields(t, f.Type, path+"."+f.Name) + } + case reflect.Pointer, reflect.Slice, reflect.Array: + assertNoFuncFields(t, ty.Elem(), path+"[]") + case reflect.Map: + assertNoFuncFields(t, ty.Elem(), path+"[v]") + } +} + +// TestValidateSpecsHasTeeth proves the validator reports (never panics on) +// concrete violations and returns clean for a well-formed synthetic set. +func TestValidateSpecsHasTeeth(t *testing.T) { + t.Parallel() + + good := Spec{ + Key: "beads.conditional_writes", Category: InfraRollout, + ConfigPath: "beads.conditional_writes", EnvOverride: "GC_X", EnvSemantics: EnvOverrides, + Default: Default{Mode: ptr(Off)}, Owner: Owner{Bead: "b", GitHub: "@t"}, + Expires: "2027-01-15", VersionAnchor: "ANCHOR", + SelectsBetween: [2]string{"a", "b"}, Justification: "why", + } + if errs := ValidateSpecs([]Spec{good}); len(errs) != 0 { + t.Fatalf("well-formed spec rejected: %v", errs) + } + + // Each row asserts a SPECIFIC error substring, so a sibling rule that also + // fires cannot vacuously satisfy the row (the masking bug the red team found). + rows := []struct { + name string + mut func(s *Spec) + want string + }{ + {"empty key", func(s *Spec) { s.Key = "" }, "empty Key"}, + {"bad category", func(s *Spec) { s.Category = "agent-capability" }, "invalid Category"}, + {"both default arms", func(s *Spec) { s.Default = Default{Mode: ptr(Off), Bool: ptr(true)} }, "both Mode and Bool"}, + {"no default arm", func(s *Spec) { s.Default = Default{} }, "neither Mode nor Bool"}, + {"empty configpath", func(s *Spec) { s.ConfigPath = "" }, "empty ConfigPath"}, + {"unresolvable configpath", func(s *Spec) { s.ConfigPath = "beads.nope_nope" }, "does not resolve"}, + {"non-leaf configpath", func(s *Spec) { s.ConfigPath = "beads" }, "string config field"}, + {"mode arm on bool field", func(s *Spec) { s.ConfigPath = "daemon.formula_v2" }, "string config field"}, + {"bool arm on string field", func(s *Spec) { s.Default = Default{Bool: ptr(true)} }, "bool/*bool config field"}, + {"non-GC env", func(s *Spec) { s.EnvOverride = "X" }, "GC_-prefixed"}, + {"invalid envsemantics", func(s *Spec) { s.EnvSemantics = "bogus" }, "EnvSemantics"}, + {"missing owner bead", func(s *Spec) { s.Owner.Bead = "" }, "Owner requires"}, + {"missing owner github", func(s *Spec) { s.Owner.GitHub = "" }, "Owner requires"}, + {"rollout missing expires", func(s *Spec) { s.Expires = "" }, "requires Expires"}, + {"rollout malformed expires", func(s *Spec) { s.Expires = "2027-1-5" }, "not YYYY-MM-DD"}, + {"rollout missing anchor", func(s *Spec) { s.VersionAnchor = "" }, "requires a VersionAnchor"}, + {"empty selectsbetween arm", func(s *Spec) { s.SelectsBetween = [2]string{"a", ""} }, "two non-empty"}, + {"identical selectsbetween", func(s *Spec) { s.SelectsBetween = [2]string{"x", "x"} }, "must differ"}, + {"empty justification", func(s *Spec) { s.Justification = "" }, "empty Justification"}, + } + for _, tc := range rows { + s := good + tc.mut(&s) + if errs := ValidateSpecs([]Spec{s}); !hasErr(errs, tc.want) { + t.Errorf("%s: want an error containing %q, got %v", tc.name, tc.want, errs) + } + } + + // killswitch anchor rules, each in isolation (no sibling masking). + ksExpires := good + ksExpires.Category, ksExpires.VersionAnchor = InfraKillswitch, "" + if !hasErr(ValidateSpecs([]Spec{ksExpires}), "killswitch must not set Expires") { + t.Errorf("killswitch with Expires not rejected: %v", ValidateSpecs([]Spec{ksExpires})) + } + ksAnchor := good + ksAnchor.Category, ksAnchor.Expires = InfraKillswitch, "" + if !hasErr(ValidateSpecs([]Spec{ksAnchor}), "killswitch must not set VersionAnchor") { + t.Errorf("killswitch with VersionAnchor not rejected: %v", ValidateSpecs([]Spec{ksAnchor})) + } + // a clean killswitch (no lifecycle anchors) validates. + ksClean := good + ksClean.Category, ksClean.Expires, ksClean.VersionAnchor = InfraKillswitch, "", "" + if errs := ValidateSpecs([]Spec{ksClean}); len(errs) != 0 { + t.Errorf("clean killswitch rejected: %v", errs) + } +} + +// TestDuplicateKeysAndEnvRejected proves cross-spec uniqueness. +func TestDuplicateKeysAndEnvRejected(t *testing.T) { + t.Parallel() + base := Spec{ + Key: "k1", Category: InfraKillswitch, ConfigPath: "beads.conditional_writes", + EnvOverride: "GC_DUP", EnvSemantics: EnvOverrides, Default: Default{Mode: ptr(Off)}, + Owner: Owner{Bead: "b", GitHub: "@t"}, SelectsBetween: [2]string{"a", "b"}, Justification: "x", + } + other := base + other.Key = "k2" + if errs := ValidateSpecs([]Spec{base, other}); len(errs) == 0 { + t.Errorf("duplicate EnvOverride across specs should be rejected") + } + dupKey := base + dupKey.EnvOverride, dupKey.EnvSemantics = "", "" + dupKey2 := dupKey + if errs := ValidateSpecs([]Spec{dupKey, dupKey2}); len(errs) == 0 { + t.Errorf("duplicate Key across specs should be rejected") + } +} + +// TestDefaultsDoNotDrift pins the three homes of each gate's default together: +// the Spec.Default, the defaultFlags() value Resolve/ForTest start from, and the +// config accessor. A drift here is the classic feature-flag silent-default bug. +func TestDefaultsDoNotDrift(t *testing.T) { + t.Parallel() + byKey := map[string]Spec{} + for _, s := range Specs() { + byKey[s.Key] = s + } + def := defaultFlags() + + // beads.conditional_writes: Mode gate, default Off. + beads := byKey[keyBeadsConditionalWrites] + if beads.Default.Mode == nil || *beads.Default.Mode != Off { + t.Fatalf("beads Spec.Default = %v, want Off", beads.Default.Mode) + } + if def.BeadsConditionalWrites() != Off { + t.Errorf("defaultFlags beads = %q, want off", def.BeadsConditionalWrites()) + } + if got := (config.BeadsConfig{}).NormalizedConditionalWrites(); got != string(Off) { + t.Errorf("config accessor default = %q, want %q", got, Off) + } + + // daemon.formula_v2: bool gate, default true. + fv2 := byKey[keyDaemonFormulaV2] + if fv2.Default.Bool == nil || *fv2.Default.Bool != true { + t.Fatalf("formula_v2 Spec.Default = %v, want true", fv2.Default.Bool) + } + if !def.FormulaV2() { + t.Errorf("defaultFlags formula_v2 = false, want true") + } + if !(config.DaemonConfig{}).FormulaV2Enabled() { + t.Errorf("config accessor formula_v2 default = false, want true") + } +} diff --git a/internal/rollout/resolve.go b/internal/rollout/resolve.go new file mode 100644 index 0000000000..2d8a44d0b0 --- /dev/null +++ b/internal/rollout/resolve.go @@ -0,0 +1,108 @@ +package rollout + +import ( + "fmt" + "os" + + "github.com/gastownhall/gascity/internal/config" +) + +// ResolveOptions carries the injected seams. The zero value is production +// behavior (os.LookupEnv). Tests inject a map-backed LookupEnv — never t.Setenv. +type ResolveOptions struct { + // LookupEnv defaults to os.LookupEnv when nil. It is never read at package + // init; it is consulted only inside Resolve. + LookupEnv func(key string) (string, bool) +} + +// Resolve computes the immutable Flags value once per process from the +// already-merged config plus env overrides. Precedence is built-in default < +// config < env (per each gate's EnvSemantics), with a typed Origin and typed +// Notices recorded ON the returned Flags. +// +// A malformed env value NEVER fails Resolve: it records a NoticeInvalidEnvIgnored +// and keeps the config-resolved value (warn-and-use-config, never +// refuse-to-start). The error return is reserved for structural failures only: +// a nil cfg, or an out-of-enum non-empty CONFIG value (a config typo can never +// silently mean "off"). +func Resolve(cfg *config.City, opts ResolveOptions) (Flags, error) { + if cfg == nil { + return Flags{}, fmt.Errorf("rollout: Resolve requires a non-nil config") + } + lookup := opts.LookupEnv + if lookup == nil { + lookup = os.LookupEnv + } + + f := defaultFlags() + + // beads.conditional_writes — Mode gate, EnvOverrides semantics. + if err := resolveBeadsConditionalWrites(cfg, lookup, &f); err != nil { + return Flags{}, err + } + + // daemon.formula_v2 — bool migration gate, no env override. + if value, defined := readDaemonFormulaV2(cfg); defined { + f.formulaV2 = resolved[bool]{value: value, origin: OriginConfig} + } + + return f, nil +} + +func resolveBeadsConditionalWrites(cfg *config.City, lookup func(string) (string, bool), f *Flags) error { + // The env var NAME and precedence semantics come from the registry Spec, so + // the CODEOWNERS-reviewed registry is the single source of truth — renaming + // Spec.EnvOverride or flipping EnvSemantics changes behavior here, and the + // registry↔resolver binding test proves it. + spec := specByKey(keyBeadsConditionalWrites) + + raw, defined := readBeadsConditionalWrites(cfg) + mode, origin := Off, OriginBuiltin + if defined { + m, err := ParseMode(raw) + if err != nil { + return fmt.Errorf("rollout: config %s: %w", keyBeadsConditionalWrites, err) + } + mode, origin = m, OriginConfig + } + + if spec.EnvOverride != "" { + if envRaw, ok := lookup(spec.EnvOverride); ok { + m, err := ParseMode(envRaw) + switch { + case err != nil: + // Malformed value: warn and keep the config-resolved value. Never + // refuse-to-start, never a silent fallback. + f.notices = append(f.notices, Notice{ + Kind: NoticeInvalidEnvIgnored, FlagKey: keyBeadsConditionalWrites, + EnvVar: spec.EnvOverride, ConfigValue: raw, EnvValue: envRaw, + Message: fmt.Sprintf("%s=%q is not off|auto|require; ignored, keeping %s=%q (%s)", + spec.EnvOverride, envRaw, keyBeadsConditionalWrites, string(mode), origin), + }) + case spec.EnvSemantics == EnvFillsNil && defined: + // fills-nil: config already set, so the env value does not apply. + // No override, no misleading notice. + case defined && m != mode: + f.notices = append(f.notices, Notice{ + Kind: NoticeEnvOverridesConfig, FlagKey: keyBeadsConditionalWrites, + EnvVar: spec.EnvOverride, ConfigValue: raw, EnvValue: envRaw, + Message: fmt.Sprintf("%s=%q overrides config %s=%q", spec.EnvOverride, string(m), keyBeadsConditionalWrites, raw), + }) + mode, origin = m, OriginEnv + case defined && m == mode: + // Env agrees with an explicit config value: redundant, so keep the + // config origin and emit no (misleading "config unset") notice. + default: // !defined: env supplies the value. + f.notices = append(f.notices, Notice{ + Kind: NoticeEnvOverrideActive, FlagKey: keyBeadsConditionalWrites, + EnvVar: spec.EnvOverride, ConfigValue: raw, EnvValue: envRaw, + Message: fmt.Sprintf("%s=%q applied (config unset)", spec.EnvOverride, string(m)), + }) + mode, origin = m, OriginEnv + } + } + } + + f.beadsConditionalWrites = resolved[Mode]{value: mode, origin: origin} + return nil +} diff --git a/internal/rollout/resolve_test.go b/internal/rollout/resolve_test.go new file mode 100644 index 0000000000..6360b0e5b8 --- /dev/null +++ b/internal/rollout/resolve_test.go @@ -0,0 +1,157 @@ +package rollout + +import ( + "testing" + + "github.com/gastownhall/gascity/internal/config" +) + +func envMap(m map[string]string) func(string) (string, bool) { + return func(k string) (string, bool) { v, ok := m[k]; return v, ok } +} + +func cityWith(conditionalWrites string, formulaV2 *bool) *config.City { + return &config.City{ + Beads: config.BeadsConfig{ConditionalWrites: conditionalWrites}, + Daemon: config.DaemonConfig{FormulaV2: formulaV2}, + } +} + +// TestResolvePrecedence walks builtin < config < env for the Mode gate with an +// injected LookupEnv (never t.Setenv), and the config/builtin path for the bool +// gate. +func TestResolvePrecedence(t *testing.T) { + t.Parallel() + env := func(m map[string]string) ResolveOptions { return ResolveOptions{LookupEnv: envMap(m)} } + // Source the env key from the single-source const so this test breaks if the + // registry's env override name drifts. + K := envBeadsConditionalWrites + + t.Run("builtin when unset everywhere", func(t *testing.T) { + t.Parallel() + f, err := Resolve(cityWith("", nil), env(nil)) + if err != nil { + t.Fatal(err) + } + if f.BeadsConditionalWrites() != Off || f.OriginOf(keyBeadsConditionalWrites) != OriginBuiltin { + t.Errorf("beads = %q/%q, want off/builtin", f.BeadsConditionalWrites(), f.OriginOf(keyBeadsConditionalWrites)) + } + if !f.FormulaV2() || f.OriginOf(keyDaemonFormulaV2) != OriginBuiltin { + t.Errorf("formula_v2 = %v/%q, want true/builtin", f.FormulaV2(), f.OriginOf(keyDaemonFormulaV2)) + } + }) + + t.Run("config wins over builtin", func(t *testing.T) { + t.Parallel() + f, err := Resolve(cityWith("require", ptr(false)), env(nil)) + if err != nil { + t.Fatal(err) + } + if f.BeadsConditionalWrites() != Require || f.OriginOf(keyBeadsConditionalWrites) != OriginConfig { + t.Errorf("beads = %q/%q, want require/config", f.BeadsConditionalWrites(), f.OriginOf(keyBeadsConditionalWrites)) + } + if f.FormulaV2() || f.OriginOf(keyDaemonFormulaV2) != OriginConfig { + t.Errorf("formula_v2 = %v/%q, want false/config", f.FormulaV2(), f.OriginOf(keyDaemonFormulaV2)) + } + }) + + t.Run("valid env active when config unset", func(t *testing.T) { + t.Parallel() + f, err := Resolve(cityWith("", nil), env(map[string]string{K: "auto"})) + if err != nil { + t.Fatal(err) + } + if f.BeadsConditionalWrites() != Auto || f.OriginOf(keyBeadsConditionalWrites) != OriginEnv { + t.Errorf("beads = %q/%q, want auto/env", f.BeadsConditionalWrites(), f.OriginOf(keyBeadsConditionalWrites)) + } + assertOneNotice(t, f, NoticeEnvOverrideActive) + }) + + t.Run("valid env overrides config, loudly", func(t *testing.T) { + t.Parallel() + f, err := Resolve(cityWith("require", nil), env(map[string]string{K: " AUTO "})) + if err != nil { + t.Fatal(err) + } + if f.BeadsConditionalWrites() != Auto || f.OriginOf(keyBeadsConditionalWrites) != OriginEnv { + t.Errorf("beads = %q/%q, want auto/env (case+space tolerant)", f.BeadsConditionalWrites(), f.OriginOf(keyBeadsConditionalWrites)) + } + assertOneNotice(t, f, NoticeEnvOverridesConfig) + }) + + t.Run("valid env agreeing with explicit config keeps config origin, no notice", func(t *testing.T) { + t.Parallel() + f, err := Resolve(cityWith("auto", nil), env(map[string]string{K: "auto"})) + if err != nil { + t.Fatal(err) + } + if f.BeadsConditionalWrites() != Auto || f.OriginOf(keyBeadsConditionalWrites) != OriginConfig { + t.Errorf("beads = %q/%q, want auto/config (env agrees; config authoritative)", f.BeadsConditionalWrites(), f.OriginOf(keyBeadsConditionalWrites)) + } + for _, n := range f.Notices() { + if n.FlagKey == keyBeadsConditionalWrites { + t.Errorf("env agreeing with config must emit no (misleading) notice, got %+v", n) + } + } + }) + + t.Run("malformed env warns and uses config (never errors)", func(t *testing.T) { + t.Parallel() + f, err := Resolve(cityWith("require", nil), env(map[string]string{K: "yes-please"})) + if err != nil { + t.Fatalf("malformed env must NOT error: %v", err) + } + if f.BeadsConditionalWrites() != Require || f.OriginOf(keyBeadsConditionalWrites) != OriginConfig { + t.Errorf("beads = %q/%q, want require/config (config kept)", f.BeadsConditionalWrites(), f.OriginOf(keyBeadsConditionalWrites)) + } + assertOneNotice(t, f, NoticeInvalidEnvIgnored) + }) + + t.Run("out-of-enum CONFIG value errors (typo never means off)", func(t *testing.T) { + t.Parallel() + if _, err := Resolve(cityWith("requre", nil), env(nil)); err == nil { + t.Errorf("expected an error for an out-of-enum config value") + } + }) + + t.Run("nil config errors", func(t *testing.T) { + t.Parallel() + if _, err := Resolve(nil, env(nil)); err == nil { + t.Errorf("expected an error for nil config") + } + }) +} + +func assertOneNotice(t *testing.T, f Flags, kind NoticeKind) { + t.Helper() + n := 0 + for _, notice := range f.Notices() { + if notice.Kind == kind { + n++ + } + } + if n != 1 { + t.Errorf("want exactly one %q notice, got %d (all: %+v)", kind, n, f.Notices()) + } +} + +func TestParseMode(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + in string + want Mode + ok bool + }{ + {"off", Off, true}, + {"AUTO", Auto, true}, + {" Require ", Require, true}, + {"", ModeUnset, false}, + {"true", ModeUnset, false}, + {"on", ModeUnset, false}, + } { + got, err := ParseMode(tc.in) + if (err == nil) != tc.ok || (tc.ok && got != tc.want) { + t.Errorf("ParseMode(%q) = %q,%v; want %q,ok=%v", tc.in, got, err, tc.want, tc.ok) + } + } +} diff --git a/internal/rollout/spec.go b/internal/rollout/spec.go new file mode 100644 index 0000000000..4b9e0639fd --- /dev/null +++ b/internal/rollout/spec.go @@ -0,0 +1,240 @@ +package rollout + +import ( + "fmt" + "reflect" + "strings" + + "github.com/gastownhall/gascity/internal/config" +) + +// Category classifies why a gate exists. It is a CLOSED enum with no +// agent-capability member — the structural half of the "no capability flags" +// exclusion. Rollout and migration gates are terminal (their default flips, then +// the gate is deleted); only a killswitch is long-lived. +type Category string + +const ( + // InfraRollout adopts a new mechanical path (e.g. beads CAS writes). + InfraRollout Category = "infra-rollout" + // InfraMigration retires a legacy path (e.g. the formula_v2 migration). + InfraMigration Category = "infra-migration" + // InfraKillswitch is an emergency off with no expiry — the only long-lived + // category. + InfraKillswitch Category = "infra-killswitch" +) + +// EnvSemantics pins how a Spec's env override interacts with explicit config. +type EnvSemantics string + +const ( + // EnvOverrides makes a valid env value beat explicit config (break-glass; + // the default for a new gate). + EnvOverrides EnvSemantics = "overrides" + // EnvFillsNil applies the env value only when config left the field unset. + EnvFillsNil EnvSemantics = "fills-nil" +) + +// Default carries the built-in value. Exactly one arm is set, and the set arm +// fixes the gate's value kind (Mode vs bool). Enforced by ValidateSpecs. +type Default struct { + Mode *Mode + Bool *bool +} + +// Owner is dual: Bead tracks the work item; GitHub is the named human/team that +// CODEOWNERS review and the lifecycle radar can actually reach. +type Owner struct { + Bead string // e.g. "ga-xxxxx" + GitHub string // "@handle" or "@org/team" +} + +// Spec is one rollout-gate descriptor. It is PURE DATA — no func-valued fields +// (a capability predicate is supplied per-call, never stored here) — so +// registry.go stays CODEOWNERS-reviewable and graduation edits stay data-only. +type Spec struct { + Key string // canonical dotted name, unique, non-empty + Category Category // member of the closed enum + ConfigPath string // toml path on config.City; reflection-verified + EnvOverride string // "" or exactly one GC_*-prefixed var, unique + EnvSemantics EnvSemantics // meaningful only when EnvOverride != "" + Default Default + Owner Owner + Expires string // YYYY-MM-DD; mandatory for rollout/migration, forbidden for killswitch + VersionAnchor string // names a deps.env key (or in-repo anchor); mandatory for rollout/migration, forbidden for killswitch + SelectsBetween [2]string // the two mechanical code paths, both non-empty and distinct + Justification string // the written litmus answer; presence checked here, truth in review +} + +// ValidateSpecs reports every structural violation across specs. It takes the +// registry as a PARAMETER and returns errors (never panics), so registry_test +// validates the canonical set while subsystem tests validate throwaway []Spec +// literals with zero shared state. +func ValidateSpecs(specs []Spec) []error { + var errs []error + seenKey := map[string]bool{} + seenEnv := map[string]bool{} + for _, s := range specs { + id := s.Key + if id == "" { + errs = append(errs, fmt.Errorf("spec with empty Key: %+v", s)) + id = "" + } else if seenKey[s.Key] { + errs = append(errs, fmt.Errorf("duplicate Spec.Key %q", s.Key)) + } + seenKey[s.Key] = true + + switch s.Category { + case InfraRollout, InfraMigration, InfraKillswitch: + default: + errs = append(errs, fmt.Errorf("%s: invalid Category %q", id, s.Category)) + } + + // Exactly one Default arm, matched to the config field's kind. + switch { + case s.Default.Mode != nil && s.Default.Bool != nil: + errs = append(errs, fmt.Errorf("%s: Default sets both Mode and Bool", id)) + case s.Default.Mode == nil && s.Default.Bool == nil: + errs = append(errs, fmt.Errorf("%s: Default sets neither Mode nor Bool", id)) + } + + // ConfigPath must resolve against config.City and match the value kind. + if s.ConfigPath == "" { + errs = append(errs, fmt.Errorf("%s: empty ConfigPath", id)) + } else if ft, ok := configFieldType(s.ConfigPath); !ok { + errs = append(errs, fmt.Errorf("%s: ConfigPath %q does not resolve to a config.City field", id, s.ConfigPath)) + } else if kerr := checkDefaultMatchesField(id, s.Default, ft); kerr != nil { + errs = append(errs, kerr) + } + + // Env override hygiene. + if s.EnvOverride != "" { + if !strings.HasPrefix(s.EnvOverride, "GC_") { + errs = append(errs, fmt.Errorf("%s: EnvOverride %q must be GC_-prefixed", id, s.EnvOverride)) + } + if seenEnv[s.EnvOverride] { + errs = append(errs, fmt.Errorf("%s: duplicate EnvOverride %q", id, s.EnvOverride)) + } + seenEnv[s.EnvOverride] = true + switch s.EnvSemantics { + case EnvOverrides, EnvFillsNil: + default: + errs = append(errs, fmt.Errorf("%s: EnvOverride set but EnvSemantics %q invalid", id, s.EnvSemantics)) + } + } + + // Owner is always required. + if s.Owner.Bead == "" || s.Owner.GitHub == "" { + errs = append(errs, fmt.Errorf("%s: Owner requires both Bead and GitHub", id)) + } + + // Lifecycle anchors: mandatory for rollout/migration, forbidden for killswitch. + terminal := s.Category == InfraRollout || s.Category == InfraMigration + if terminal { + if s.Expires == "" { + errs = append(errs, fmt.Errorf("%s: %s gate requires Expires (YYYY-MM-DD)", id, s.Category)) + } else if !isYYYYMMDD(s.Expires) { + errs = append(errs, fmt.Errorf("%s: Expires %q is not YYYY-MM-DD", id, s.Expires)) + } + if s.VersionAnchor == "" { + errs = append(errs, fmt.Errorf("%s: %s gate requires a VersionAnchor", id, s.Category)) + } + } else { // killswitch + if s.Expires != "" { + errs = append(errs, fmt.Errorf("%s: killswitch must not set Expires", id)) + } + if s.VersionAnchor != "" { + errs = append(errs, fmt.Errorf("%s: killswitch must not set VersionAnchor", id)) + } + } + + if s.SelectsBetween[0] == "" || s.SelectsBetween[1] == "" { + errs = append(errs, fmt.Errorf("%s: SelectsBetween needs two non-empty paths", id)) + } else if s.SelectsBetween[0] == s.SelectsBetween[1] { + errs = append(errs, fmt.Errorf("%s: SelectsBetween paths must differ", id)) + } + + if s.Justification == "" { + errs = append(errs, fmt.Errorf("%s: empty Justification", id)) + } + } + return errs +} + +// checkDefaultMatchesField verifies the Default arm agrees with the config +// field's kind: a Mode gate maps to a string field; a bool gate maps to a bool +// or *bool field. +func checkDefaultMatchesField(id string, d Default, ft reflect.Type) error { + switch { + case d.Mode != nil: + if ft.Kind() != reflect.String { + return fmt.Errorf("%s: Mode gate expects a string config field, got %s", id, ft.Kind()) + } + case d.Bool != nil: + k := ft.Kind() + if k == reflect.Pointer { + k = ft.Elem().Kind() + } + if k != reflect.Bool { + return fmt.Errorf("%s: bool gate expects a bool/*bool config field, got %s", id, ft.Kind()) + } + } + return nil +} + +// configFieldType walks config.City by dotted toml path and returns the type of +// the addressed field. Pointer-to-struct segments are dereferenced during the +// walk; the final field's own type (pointer included) is returned. +func configFieldType(path string) (reflect.Type, bool) { + t := reflect.TypeOf(config.City{}) + segs := strings.Split(path, ".") + for i, seg := range segs { + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return nil, false + } + f, ok := fieldByTOMLName(t, seg) + if !ok { + return nil, false + } + if i == len(segs)-1 { + return f.Type, true + } + t = f.Type + } + return nil, false +} + +// fieldByTOMLName finds the struct field whose toml tag name equals name. +func fieldByTOMLName(t reflect.Type, name string) (reflect.StructField, bool) { + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + tag := f.Tag.Get("toml") + if tag == "" || tag == "-" { + continue + } + if before, _, _ := strings.Cut(tag, ","); before == name { + return f, true + } + } + return reflect.StructField{}, false +} + +// isYYYYMMDD reports whether s is a plausible YYYY-MM-DD date (shape only; not a +// calendar check — this is a lint of the field, not a merge-blocking clock). +func isYYYYMMDD(s string) bool { + if len(s) != 10 || s[4] != '-' || s[7] != '-' { + return false + } + for i, r := range s { + if i == 4 || i == 7 { + continue + } + if r < '0' || r > '9' { + return false + } + } + return true +} diff --git a/internal/rollout/testenv_import_test.go b/internal/rollout/testenv_import_test.go new file mode 100644 index 0000000000..6d10b58a9e --- /dev/null +++ b/internal/rollout/testenv_import_test.go @@ -0,0 +1,5 @@ +// Code generated by go run scripts/add-testenv-import.go; DO NOT EDIT. + +package rollout + +import _ "github.com/gastownhall/gascity/internal/testenv" diff --git a/internal/testenv/testenv.go b/internal/testenv/testenv.go index 0198a29dff..b2d9833f81 100644 --- a/internal/testenv/testenv.go +++ b/internal/testenv/testenv.go @@ -101,7 +101,9 @@ const PassthroughVar = "GC_TESTENV_PASSTHROUGH" // actually unsets. TestDoltPortVarsAreLeakVectors enforces that pairing. // Test-gate vars (GC_FAST_UNIT, GC_REAL_PROCESS_SIGNAL_TESTS, // GC_DOLT_REAL_BINARY, ...) do NOT belong here; they're how tests opt into -// expensive paths. +// expensive paths. Rollout-gate env overrides (internal/rollout registry +// EnvOverride names) DO belong here: a developer's shell value must not leak in +// and non-deterministically flip a gate's resolved mode during a test. var LeakVectorVars = []string{ "BEADS_DIR", "BEADS_DOLT_PASSWORD", @@ -113,6 +115,7 @@ var LeakVectorVars = []string{ "GC_AGENT", "GC_ALIAS", "GC_BEADS", + "GC_BEADS_CONDITIONAL_WRITES", "GC_BEADS_SCOPE_ROOT", "GC_BIN", "GC_CITY", From 84782c8c5329d4b502982b79fc86e71917b6a0d9 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Thu, 9 Jul 2026 16:08:49 -0700 Subject: [PATCH 038/225] fix(init): default the [api] port on the hosted-dolt init path (#4112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What On the hosted-dolt `gc init` path, the `[api]` port was not being defaulted when no `[api]` section was present, leaving the initialized city without a usable API port unless a bootstrap profile pinned one. ## How Apply the standard `[api]` port default during hosted-dolt init when no `[api]` section is set, and preserve an API config already pinned by a bootstrap profile. ## Tests - `TestHostedDoltInitAppliesAPIPortDefault` (init_hosted_dolt_test.go) with two cases: - defaults the API port when no `[api]` section is set - preserves an API config already pinned by a bootstrap profile Verified locally: `go vet ./cmd/gc` clean; `TestHostedDoltInitAppliesAPIPortDefault` passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/gc/init_hosted_dolt.go | 11 ++++++++++ cmd/gc/init_hosted_dolt_test.go | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/cmd/gc/init_hosted_dolt.go b/cmd/gc/init_hosted_dolt.go index 5223174219..a8e664c95d 100644 --- a/cmd/gc/init_hosted_dolt.go +++ b/cmd/gc/init_hosted_dolt.go @@ -135,6 +135,17 @@ func (o hostedDoltInitOptions) applyToCityConfig(cfg *config.City) error { } cfg.Dolt.Host = strings.TrimSpace(o.Host) cfg.Dolt.Port = port + // A hosted city's controller runs out-of-session; the control dispatcher and + // gc CLI reach it only through the HTTP API, and every API consumer treats + // cfg.API.Port == 0 as "API disabled". Neither plain init nor the hosted + // endpoint flags write an [api] section (only the k8s-cell bootstrap profile + // does), so default the API port here — otherwise a hosted init yields a city + // whose control plane is unreachable until an [api] section is hand-added. + // applyBootstrapProfile runs first, so a profile that already pinned a + // port/bind (e.g. k8s-cell's 0.0.0.0 + allow_mutations) wins. + if cfg.API.Port == 0 { + cfg.API.Port = config.DefaultAPIPort + } return nil } diff --git a/cmd/gc/init_hosted_dolt_test.go b/cmd/gc/init_hosted_dolt_test.go index b31760d384..59ba9e139e 100644 --- a/cmd/gc/init_hosted_dolt_test.go +++ b/cmd/gc/init_hosted_dolt_test.go @@ -163,6 +163,42 @@ func TestHostedDoltInitOptionsValidate(t *testing.T) { } } +// TestHostedDoltInitAppliesAPIPortDefault pins the control-plane reachability +// contract for hosted cities. A hosted city's controller runs out-of-session, +// so the control dispatcher and gc CLI reach it only through the HTTP API, and +// every API consumer treats cfg.API.Port == 0 as "API disabled". Neither plain +// init nor the hosted endpoint flags write an [api] section (only the k8s-cell +// bootstrap profile does), so without this default a hosted init yields a city +// whose control plane is unreachable until an [api] section is hand-added. +func TestHostedDoltInitAppliesAPIPortDefault(t *testing.T) { + t.Run("defaults the API port when no [api] section is set", func(t *testing.T) { + o := hostedDoltInitOptions{Host: "gateway.example.com", Port: "4406", Database: "bd_prj_x", ProjectID: "prj_x"} + var cfg config.City + if err := o.applyToCityConfig(&cfg); err != nil { + t.Fatalf("applyToCityConfig() error = %v", err) + } + if cfg.API.Port != config.DefaultAPIPort { + t.Fatalf("cfg.API.Port = %d, want %d (hosted controller is reachable only via the HTTP API)", cfg.API.Port, config.DefaultAPIPort) + } + }) + t.Run("preserves an API config already pinned by a bootstrap profile", func(t *testing.T) { + o := hostedDoltInitOptions{Host: "gateway.example.com", Port: "4406", Database: "bd_prj_x", ProjectID: "prj_x"} + var cfg config.City + cfg.API.Port = 12345 + cfg.API.Bind = "0.0.0.0" + cfg.API.AllowMutations = true + if err := o.applyToCityConfig(&cfg); err != nil { + t.Fatalf("applyToCityConfig() error = %v", err) + } + if cfg.API.Port != 12345 { + t.Fatalf("cfg.API.Port = %d, want 12345 preserved (bootstrap profile wins)", cfg.API.Port) + } + if cfg.API.Bind != "0.0.0.0" || !cfg.API.AllowMutations { + t.Fatalf("bootstrap-profile API config clobbered: bind=%q allowMutations=%v", cfg.API.Bind, cfg.API.AllowMutations) + } + }) +} + func TestInitWizardConfigFromFlagsCapturesHostedDolt(t *testing.T) { cmd := newInitCmd(io.Discard, io.Discard) if err := cmd.Flags().Set("template", "custom"); err != nil { From 64414cf18165347fe9a399bd889e3c33f6f0a5ba Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Thu, 9 Jul 2026 16:25:35 -0700 Subject: [PATCH 039/225] simplify(S02): per-bead dirty overlay (stop declining the whole cache on one dirty bead) (#4115) Per-bead dirty overlay: cached List/Count/Ready refresh ONLY the dirty IDs via bounded backing.Get (|dirty| round-trips, cap 8, retry-once-then-fallback) instead of the len(c.dirty)>0 O(active-set) full backing scan (#2987 amplifier). Behavior-preserving: reads are byte-equivalent to the old full-scan-on-dirty path on every backing shape incl. DoltliteReadStore (the R1 blocked-bead-served-as-ready / empty-DepList regression is closed via depsFromFieldsIfCarried + explicit backing.DepList; dep-less-shape differential test proves it). Suppressed-ID fence added. Strict cache-only handles (CachedList/CachedReady/cached*Only) deliberately keep the dirty=>decline #2210 defense (out of scope for a behavior-preserving change; recorded in the spec). Existing ~beads corpus unmodified; -race clean. #2987/#2153; builds on merged S01 (#4046/#4092). --------- Co-authored-by: Claude Opus 4.8 (1M context) --- internal/beads/caching_store.go | 216 ++++ internal/beads/caching_store_overlay_test.go | 1216 ++++++++++++++++++ internal/beads/caching_store_reads.go | 129 +- 3 files changed, 1500 insertions(+), 61 deletions(-) create mode 100644 internal/beads/caching_store_overlay_test.go diff --git a/internal/beads/caching_store.go b/internal/beads/caching_store.go index d6604d2a12..e24bb58fb5 100644 --- a/internal/beads/caching_store.go +++ b/internal/beads/caching_store.go @@ -485,6 +485,222 @@ func (c *CachingStore) clearStalenessMarksLocked(id string) { delete(c.deletedSeq, id) } +// dirtyOverlayMaxGets bounds the inline per-ID refresh a cached read will do +// before it declines the overlay and falls back to today's full backing scan. +// Above the cap the read degrades to prior behavior — never worse. +const dirtyOverlayMaxGets = 8 + +// errDirtyOverlayFallback signals that a cached read must take its existing +// fallback path (backing.List / backing.Ready / ErrCacheUnavailable / ok=false, +// each unchanged per site). It never escapes the read site. +var errDirtyOverlayFallback = errors.New("beads cache: dirty overlay fallback") + +// cacheServableLocked reports whether the active read model can answer from +// cache: the cache is live or partial and the prime was not a partial error. +// Dirty is no longer a serve-blocker — it is handled by readCacheWithOverlay. +// Caller must hold c.mu (read or write). +func (c *CachingStore) cacheServableLocked() bool { + return (c.state == cacheLive || c.state == cachePartial) && c.primePartialErr == nil +} + +// readCacheWithOverlay serves a cached read after refreshing only the dirty +// rows, replacing the old "one dirty bead declines the whole cache" tripwire. +// +// gate reports, under the lock, whether the cache is servable for this read +// shape (cacheServableLocked for most sites; Ready adds depsComplete). collect +// materializes the read from the cache and is invoked exactly once, while the +// lock is held, only after every dirty row has been refreshed or confirmed +// absent — so no dirty row is ever served (I1) and no new mark can slip between +// the servability re-check and the serve (I7). suppressed holds IDs that +// backing.Get reported ErrNotFound this pass; collect must omit them, matching +// what the old full backing.List would have returned for deleted rows (I6). A +// suppressed id that a concurrent apply resurrects between fetch and re-lock is +// caught by retrySuppressedChurnLocked and re-fetched, the symmetric fence to +// the fetched-row deletedSeq/beadSeq check, so the serve never omits a now-live +// row (I6). +// +// A non-nil error means the caller must take its existing fallback path (I5): +// the dirty set exceeds dirtyOverlayMaxGets, a backing.Get failed with a +// non-NotFound error, the cache is not servable, or residual dirty churn +// survived the bounded retry. No backing I/O happens under c.mu (I7). +func (c *CachingStore) readCacheWithOverlay(gate func() bool, collect func(suppressed map[string]struct{})) error { + suppressed := make(map[string]struct{}) + for pass := 0; pass < 2; pass++ { + c.mu.RLock() + if !gate() { + c.mu.RUnlock() + return errDirtyOverlayFallback + } + startSeq := c.mutationSeq + todo := c.dirtyToRefreshLocked(suppressed) + if len(todo) == 0 { + // Cache is clean, or every remaining dirty row is a confirmed + // absence: serve from cache under this same lock hold — but only + // after re-verifying no suppressed row was resurrected (see below). + if c.retrySuppressedChurnLocked(suppressed, startSeq) { + c.mu.RUnlock() + continue + } + collect(suppressed) + c.mu.RUnlock() + return nil + } + if len(c.dirty) > dirtyOverlayMaxGets { + c.mu.RUnlock() + return errDirtyOverlayFallback + } + c.mu.RUnlock() + + fetched, err := c.fetchDirtyOverlay(todo, suppressed) + if err != nil { + return errDirtyOverlayFallback + } + + c.mu.Lock() + if !gate() { + c.mu.Unlock() + return errDirtyOverlayFallback + } + now := time.Now() + absorbed := 0 + for _, f := range fetched { + // Fence discipline (I3): never overwrite a mutation that landed + // after the snapshot. A skipped-but-still-dirty row is caught by + // the re-check below and handled by the retry-or-fallback. + if c.deletedSeq[f.id] > startSeq || c.beadSeq[f.id] > startSeq { + continue + } + opts := absorbOpts{ + depsMode: depsFromFields, + seqMode: seqClearBeadSeqOnly, + clearDirty: true, + } + // R1: rows whose backing.Get carried no dependency fields had their + // authoritative deps fetched separately; install them verbatim so the + // overlay never clobbers a blocked bead's deps to nil. + if f.depsFromBacking { + opts.depsMode = depsExplicit + opts.deps = f.deps + } + c.absorbFreshLocked(f.id, f.bead, now, opts) + absorbed++ + } + if absorbed > 0 { + c.markFreshLocked(now) + c.updateStatsLocked() + } + if len(c.dirtyToRefreshLocked(suppressed)) == 0 { + if c.retrySuppressedChurnLocked(suppressed, startSeq) { + c.mu.Unlock() + continue + } + collect(suppressed) + c.mu.Unlock() + return nil + } + c.mu.Unlock() + } + return errDirtyOverlayFallback +} + +// retrySuppressedChurnLocked guards the serve against a torn read caused by an +// ErrNotFound-suppressed row being re-installed by a concurrent event-apply +// between its fetch and this final lock hold (the symmetric fence to the +// fetched-row deletedSeq/beadSeq check). A suppressed id is churn if its fence +// advanced past the snapshot, or a resident non-dirty row is now present — in +// either case omitting it from collect would serve the cache MINUS a now-live +// row. Any such id is dropped from suppressed so the next pass re-fetches it, +// and the function reports true to signal the caller must retry (or, on the +// final pass, fall back). Caller must hold c.mu. Returns false when the serve +// may proceed. +func (c *CachingStore) retrySuppressedChurnLocked(suppressed map[string]struct{}, startSeq uint64) bool { + if len(suppressed) == 0 { + return false + } + var churned []string + for id := range suppressed { + if c.beadSeq[id] > startSeq || c.deletedSeq[id] > startSeq { + churned = append(churned, id) + continue + } + if _, resident := c.beads[id]; resident { + if _, dirty := c.dirty[id]; !dirty { + churned = append(churned, id) + } + } + } + for _, id := range churned { + delete(suppressed, id) + } + return len(churned) > 0 +} + +// dirtyToRefreshLocked returns the dirty IDs still needing a backing refresh: +// every dirty mark not already confirmed absent this pass. Caller must hold +// c.mu (read or write). +func (c *CachingStore) dirtyToRefreshLocked(suppressed map[string]struct{}) []string { + if len(c.dirty) == 0 { + return nil + } + var todo []string + for id := range c.dirty { + if _, ok := suppressed[id]; ok { + continue + } + todo = append(todo, id) + } + return todo +} + +type overlayFetched struct { + id string + bead Bead + // deps holds the authoritative dependency row pulled from backing.DepList, + // set only when depsFromBacking is true. + deps []Dep + // depsFromBacking is true when the fetched bead carried no dependency fields + // and deps was sourced from an explicit backing.DepList instead. The absorb + // then installs deps verbatim (depsExplicit) rather than recomputing from the + // bead's — absent — fields. + depsFromBacking bool +} + +// fetchDirtyOverlay fetches each dirty ID via backing.Get with no lock held +// (I7). Successful Gets are queued for absorb; ErrNotFound IDs are added to +// suppressed (their dirty mark is deliberately left set, mirroring Get's dirty +// path — convergence stays with the reconciler). Any other error returns +// non-nil so the caller falls back. +// +// R1 (gastownhall/gascity#2987 class): a backing whose Get carries no dependency +// fields — the fork's flagship native DoltLite read store — would, if absorbed +// with depsFromFields, have its cached deps clobbered to nil. For such rows the +// authoritative deps are pulled here via backing.DepList (still lock-free) so the +// absorb can install them explicitly and a blocked bead is never served as ready. +func (c *CachingStore) fetchDirtyOverlay(todo []string, suppressed map[string]struct{}) ([]overlayFetched, error) { + fetched := make([]overlayFetched, 0, len(todo)) + for _, id := range todo { + fresh, err := c.backing.Get(id) + switch { + case err == nil: + row := overlayFetched{id: id, bead: fresh} + if !beadCarriesDependencyFields(fresh) { + deps, depErr := c.backing.DepList(id, "down") + if depErr != nil { + return nil, depErr + } + row.deps = deps + row.depsFromBacking = true + } + fetched = append(fetched, row) + case errors.Is(err, ErrNotFound): + suppressed[id] = struct{}{} + default: + return nil, err + } + } + return fetched, nil +} + // PrimeActive loads the common active bead statuses (open + in_progress) across // both persistent issues and ephemeral wisps into the cache. These are fast indexed // queries that populate enough data for diff --git a/internal/beads/caching_store_overlay_test.go b/internal/beads/caching_store_overlay_test.go new file mode 100644 index 0000000000..dce3420507 --- /dev/null +++ b/internal/beads/caching_store_overlay_test.go @@ -0,0 +1,1216 @@ +package beads + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "math/rand" + "slices" + "sort" + "sync" + "sync/atomic" + "testing" + "time" +) + +// counterMemStore is a MemStore that also implements Counter, so the +// differential can assert Count parity in the cap+1 regime where the overlay +// declines and Count delegates to the backing Counter (matching pre-change +// behavior for a Counter-capable backing such as the production BdStore). +type counterMemStore struct { + *MemStore +} + +func (s counterMemStore) Count(_ context.Context, query ListQuery, excludeTypes ...string) (int, error) { + rows, err := s.List(query) + if err != nil { + return 0, err + } + n := 0 + for _, b := range rows { + if slices.Contains(excludeTypes, b.Type) { + continue + } + n++ + } + return n, nil +} + +// Ready honors IsBlocked via cachedBeadReady, matching the production SQL ready +// reader. Plain MemStore.Ready ignores IsBlocked, which would make the clean +// cache twin diverge from backing.Ready in the cap+1 fallback regime; a +// faithful backing keeps the twin a valid oracle across every dirty regime. +func (s counterMemStore) Ready(query ...ReadyQuery) ([]Bead, error) { + q := readyQueryFromArgs(query) + all, err := s.List(ListQuery{AllowScan: true, IncludeClosed: true, TierMode: TierBoth}) + if err != nil { + return nil, err + } + statusByID := make(map[string]string, len(all)) + for _, b := range all { + statusByID[b.ID] = b.Status + } + now := time.Now().UTC() + var result []Bead + for _, b := range all { + if !IsReadyCandidateForTier(b, now, q.TierMode) { + continue + } + if q.Assignee != "" && b.Assignee != q.Assignee { + continue + } + deps, derr := s.DepList(b.ID, "down") + if derr != nil { + return nil, derr + } + if !cachedBeadReady(b, statusByID, deps) { + continue + } + result = append(result, cloneBead(b)) + } + sortBeadsReadyOrder(result) + if q.Limit > 0 && len(result) > q.Limit { + result = result[:q.Limit] + } + return result, nil +} + +// overlayCountingStore wraps a Store and records backing round-trips so the +// dirty-overlay perf assertions can prove that one dirty bead costs one +// backing.Get rather than a full backing.List/backing.Ready scan. getHook, if +// set, runs before each Get with no cache lock held so tests can inject +// mid-overlay mutations (the fence/race suite). +type overlayCountingStore struct { + Store + mu sync.Mutex + gets int + lists int + readies int + getHook func(id string) +} + +func (s *overlayCountingStore) Get(id string) (Bead, error) { + s.mu.Lock() + s.gets++ + hook := s.getHook + s.mu.Unlock() + // Fetch first so the overlay receives this (possibly soon-to-be-stale) + // snapshot, then run the hook to inject a concurrent mutation that lands + // while the overlay holds no lock — exercising the beadSeq/deletedSeq fence. + b, err := s.Store.Get(id) + if hook != nil { + hook(id) + } + return b, err +} + +func (s *overlayCountingStore) List(query ListQuery) ([]Bead, error) { + s.mu.Lock() + s.lists++ + s.mu.Unlock() + return s.Store.List(query) +} + +func (s *overlayCountingStore) Ready(query ...ReadyQuery) ([]Bead, error) { + s.mu.Lock() + s.readies++ + s.mu.Unlock() + return s.Store.Ready(query...) +} + +func (s *overlayCountingStore) counts() (gets, lists, readies int) { + s.mu.Lock() + defer s.mu.Unlock() + return s.gets, s.lists, s.readies +} + +func (s *overlayCountingStore) reset() { + s.mu.Lock() + defer s.mu.Unlock() + s.gets, s.lists, s.readies = 0, 0, 0 +} + +func (s *overlayCountingStore) setGetHook(hook func(id string)) { + s.mu.Lock() + s.getHook = hook + s.mu.Unlock() +} + +func markDirtyForTest(c *CachingStore, ids ...string) { + c.mu.Lock() + defer c.mu.Unlock() + for _, id := range ids { + c.markDirtyLocked(id) + } +} + +func beadIDSet(beads []Bead) map[string]Bead { + m := make(map[string]Bead, len(beads)) + for _, b := range beads { + m[b.ID] = b + } + return m +} + +func sortedIDs(beads []Bead) []string { + ids := make([]string, 0, len(beads)) + for _, b := range beads { + ids = append(ids, b.ID) + } + sort.Strings(ids) + return ids +} + +// assertBeadsEquivalent compares two read results as multisets keyed by ID, +// checking the full observable field set the read paths surface — not just +// Title/Status/Assignee/Type but also Labels, Metadata, and the IsBlocked +// ready-projection — so a divergence in any cached field (the #2987 regression +// clobbered deps, which surface through IsBlocked/DepList) fails the assertion. +// Order-sensitive checks are covered separately (TestOverlayPreservesSortOrder). +func assertBeadsEquivalent(t *testing.T, ctx string, got, want []Bead) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("%s: len(got)=%d want=%d\n got=%v\nwant=%v", ctx, len(got), len(want), sortedIDs(got), sortedIDs(want)) + } + gotByID := beadIDSet(got) + for _, w := range want { + g, ok := gotByID[w.ID] + if !ok { + t.Fatalf("%s: missing bead %q; got=%v want=%v", ctx, w.ID, sortedIDs(got), sortedIDs(want)) + } + if g.Title != w.Title || g.Status != w.Status || g.Assignee != w.Assignee || g.Type != w.Type { + t.Fatalf("%s: bead %q core-field mismatch\n got=%+v\nwant=%+v", ctx, w.ID, g, w) + } + if !slices.Equal(g.Labels, w.Labels) { + t.Fatalf("%s: bead %q labels mismatch got=%v want=%v", ctx, w.ID, g.Labels, w.Labels) + } + if !maps.Equal(g.Metadata, w.Metadata) { + t.Fatalf("%s: bead %q metadata mismatch got=%v want=%v", ctx, w.ID, g.Metadata, w.Metadata) + } + if !boolPtrEqual(g.IsBlocked, w.IsBlocked) { + t.Fatalf("%s: bead %q IsBlocked mismatch got=%v want=%v", ctx, w.ID, ptrStr(g.IsBlocked), ptrStr(w.IsBlocked)) + } + } +} + +func ptrStr(b *bool) string { + if b == nil { + return "nil" + } + return fmt.Sprintf("%t", *b) +} + +// assertDepsEquivalent compares two dependency rows as ID-keyed sets, ignoring +// order, so a cache that clobbered a blocked bead's deps to nil (the #2987 +// regression) diverges from the ground-truth twin. +func assertDepsEquivalent(t *testing.T, ctx string, got, want []Dep) { + t.Helper() + norm := func(deps []Dep) []string { + out := make([]string, 0, len(deps)) + for _, d := range deps { + out = append(out, fmt.Sprintf("%s->%s(%s)", d.IssueID, d.DependsOnID, d.Type)) + } + sort.Strings(out) + return out + } + g, w := norm(got), norm(want) + if !slices.Equal(g, w) { + t.Fatalf("%s: deps mismatch\n got=%v\nwant=%v", ctx, g, w) + } +} + +// TestOverlayReadEquivalenceDifferential is the headline read-equivalence test. +// For each seeded iteration it primes a store, drives it into a mixed dirty +// state (rows changed in backing, rows deleted from backing, and IDs never +// cached), then asserts every overlay-served read (List/Ready/Get/Count) is +// identical to a clean-primed twin store over the same backing — which the +// existing corpus proves equals the pre-change backing-served result. The +// twin is the ground truth: with no concurrent writers the dirty overlay must +// return exactly what a clean cache would (invariant I2). +func TestOverlayReadEquivalenceDifferential(t *testing.T) { + t.Parallel() + for _, n := range []int{0, 1, 5, 50, 500} { + for _, k := range []int{0, 1, 2, dirtyOverlayMaxGets, dirtyOverlayMaxGets + 1} { + seed := int64(n*1000 + k) + t.Run(fmt.Sprintf("n%d_k%d", n, k), func(t *testing.T) { + runOverlayDifferential(t, seed, n, k) + }) + } + } +} + +func runOverlayDifferential(t *testing.T, seed int64, n, k int) { + t.Helper() + rng := rand.New(rand.NewSource(seed)) + backing := counterMemStore{MemStore: NewMemStore()} + + statuses := []string{"open", "in_progress"} + labels := []string{"alpha", "beta", "gamma"} + assignees := []string{"", "ann", "bob"} + + var ids []string + for i := 0; i < n; i++ { + b := Bead{ + Title: fmt.Sprintf("bead-%d", i), + Status: statuses[rng.Intn(len(statuses))], + Assignee: assignees[rng.Intn(len(assignees))], + Labels: []string{labels[rng.Intn(len(labels))]}, + Metadata: map[string]string{"grp": fmt.Sprintf("g%d", rng.Intn(3))}, + } + // Some beads carry a blocking dependency on an earlier bead via Needs, + // so the fetched bead carries its dependency fields — the production + // BdStore contract the overlay's depsFromFields absorb relies on. + if i > 0 && rng.Intn(3) == 0 { + b.Needs = []string{ids[rng.Intn(len(ids))]} + } + if rng.Intn(5) == 0 { + blocked := rng.Intn(2) == 0 + b.IsBlocked = &blocked + } + created, err := backing.Create(b) + if err != nil { + t.Fatalf("seed=%d create: %v", seed, err) + } + ids = append(ids, created.ID) + } + + store := NewCachingStoreForTest(backing, nil) + if err := store.Prime(context.Background()); err != nil { + t.Fatalf("seed=%d prime: %v", seed, err) + } + + // Drive a mixed dirty state over K ids: mutate-in-backing, delete, or a + // brand-new never-cached id. Every mutated id is also marked dirty so the + // overlay is responsible for reconverging it (untouched-but-stale rows are + // out of scope for a per-bead overlay). + var dirtyIDs []string + for i := 0; i < k; i++ { + switch { + case len(ids) > 0 && rng.Intn(3) == 0: + id := ids[rng.Intn(len(ids))] + newTitle := fmt.Sprintf("mutated-%d", i) + newAssignee := assignees[rng.Intn(len(assignees))] + _ = backing.Update(id, UpdateOpts{Title: &newTitle, Assignee: &newAssignee}) + dirtyIDs = append(dirtyIDs, id) + case len(ids) > 0 && rng.Intn(2) == 0: + id := ids[rng.Intn(len(ids))] + _ = backing.Delete(id) + dirtyIDs = append(dirtyIDs, id) + default: + created, err := backing.Create(Bead{Title: fmt.Sprintf("fresh-%d", i), Status: "open"}) + if err != nil { + t.Fatalf("seed=%d create fresh: %v", seed, err) + } + dirtyIDs = append(dirtyIDs, created.ID) + } + } + markDirtyForTest(store, dirtyIDs...) + + // Ground-truth twin: a clean cache primed on the now-current backing. + twin := NewCachingStoreForTest(backing, nil) + if err := twin.Prime(context.Background()); err != nil { + t.Fatalf("seed=%d twin prime: %v", seed, err) + } + + queries := []ListQuery{ + {AllowScan: true, Sort: SortCreatedAsc}, + {Status: "open", Sort: SortCreatedAsc}, + {Status: "in_progress", Sort: SortCreatedDesc}, + {Label: "alpha", Sort: SortCreatedAsc}, + {Assignee: "ann", Sort: SortCreatedAsc}, + {Metadata: map[string]string{"grp": "g1"}, Sort: SortCreatedAsc}, + {AllowScan: true, Limit: 3, Sort: SortCreatedAsc}, + } + for i, q := range queries { + gotList, gotErr := store.List(q) + wantList, wantErr := twin.List(q) + if (gotErr == nil) != (wantErr == nil) { + t.Fatalf("seed=%d q%d List err got=%v want=%v", seed, i, gotErr, wantErr) + } + assertBeadsEquivalent(t, fmt.Sprintf("seed=%d q%d List", seed, i), gotList, wantList) + + gotCount, gErr := store.Count(context.Background(), q) + wantCount, wErr := twin.Count(context.Background(), q) + if (gErr == nil) != (wErr == nil) { + t.Fatalf("seed=%d q%d Count err got=%v want=%v", seed, i, gErr, wErr) + } + if gErr == nil && gotCount != wantCount { + t.Fatalf("seed=%d q%d Count got=%d want=%d", seed, i, gotCount, wantCount) + } + } + + gotReady, err := store.Ready() + if err != nil { + t.Fatalf("seed=%d Ready: %v", seed, err) + } + // The clean twin uses the same cachedBeadReady code the overlay serves from, + // and the faithful backing.Ready honors IsBlocked too, so the twin is a valid + // ground truth in every dirty regime (overlay-served and cap+1 fallback). + wantReady, err := twin.Ready() + if err != nil { + t.Fatalf("seed=%d twin Ready: %v", seed, err) + } + assertBeadsEquivalent(t, fmt.Sprintf("seed=%d Ready", seed), gotReady, wantReady) + + // Per-ID Get equivalence, including deleted (ErrNotFound) and fresh ids. + allIDs := append(append([]string{}, ids...), dirtyIDs...) + for _, id := range allIDs { + gotBead, gotErr := store.Get(id) + wantBead, wantErr := twin.Get(id) + if (gotErr == nil) != (wantErr == nil) { + t.Fatalf("seed=%d Get(%s) err got=%v want=%v", seed, id, gotErr, wantErr) + } + if gotErr == nil && (gotBead.Title != wantBead.Title || gotBead.Status != wantBead.Status) { + t.Fatalf("seed=%d Get(%s) got=%+v want=%+v", seed, id, gotBead, wantBead) + } + } +} + +// TestOverlayPreservesSortOrder proves the overlay-served result keeps the +// exact sort+limit order of a clean cache for a deterministic sort. +func TestOverlayPreservesSortOrder(t *testing.T) { + t.Parallel() + backing := NewMemStore() + var ids []string + for i := 0; i < 12; i++ { + created, err := backing.Create(Bead{Title: fmt.Sprintf("b%02d", i), Status: "open"}) + if err != nil { + t.Fatalf("create: %v", err) + } + ids = append(ids, created.ID) + } + store := NewCachingStoreForTest(backing, nil) + if err := store.Prime(context.Background()); err != nil { + t.Fatalf("prime: %v", err) + } + newTitle := "zzz-moved" + if err := backing.Update(ids[0], UpdateOpts{Title: &newTitle}); err != nil { + t.Fatalf("update: %v", err) + } + markDirtyForTest(store, ids[0]) + + twin := NewCachingStoreForTest(backing, nil) + if err := twin.Prime(context.Background()); err != nil { + t.Fatalf("twin prime: %v", err) + } + + for _, sort := range []SortOrder{SortCreatedAsc, SortCreatedDesc} { + q := ListQuery{AllowScan: true, Sort: sort} + got, err := store.List(q) + if err != nil { + t.Fatalf("List: %v", err) + } + want, err := twin.List(q) + if err != nil { + t.Fatalf("twin List: %v", err) + } + if len(got) != len(want) { + t.Fatalf("sort=%s len got=%d want=%d", sort, len(got), len(want)) + } + for i := range got { + if got[i].ID != want[i].ID { + t.Fatalf("sort=%s position %d got=%s want=%s", sort, i, got[i].ID, want[i].ID) + } + } + } +} + +// TestOverlayPerfRoundTripAccounting is the perf assertion: one dirty bead +// costs one backing.Get and zero backing.List/backing.Ready; a clean cache +// costs nothing; and the cap+1 case degrades to exactly today's single +// backing.List with no Gets. +func TestOverlayPerfRoundTripAccounting(t *testing.T) { + t.Parallel() + backing := &overlayCountingStore{Store: NewMemStore()} + var ids []string + for i := 0; i < 3000; i++ { + created, err := backing.Create(Bead{Title: fmt.Sprintf("b%d", i), Status: "open"}) + if err != nil { + t.Fatalf("create: %v", err) + } + ids = append(ids, created.ID) + } + store := NewCachingStoreForTest(backing, nil) + if err := store.Prime(context.Background()); err != nil { + t.Fatalf("prime: %v", err) + } + + // Clean cache: overlay adds zero backing cost. + backing.reset() + if _, err := store.List(ListQuery{Status: "open"}); err != nil { + t.Fatalf("clean List: %v", err) + } + if g, l, r := backing.counts(); g != 0 || l != 0 || r != 0 { + t.Fatalf("clean List backing calls: gets=%d lists=%d readies=%d, want 0/0/0", g, l, r) + } + + // One dirty bead: exactly one backing.Get, zero backing.List. + newTitle := "changed" + if err := backing.Update(ids[0], UpdateOpts{Title: &newTitle}); err != nil { + t.Fatalf("update: %v", err) + } + markDirtyForTest(store, ids[0]) + backing.reset() + rows, err := store.List(ListQuery{Status: "open"}) + if err != nil { + t.Fatalf("dirty List: %v", err) + } + if g, l, _ := backing.counts(); g != 1 || l != 0 { + t.Fatalf("1 dirty List backing calls: gets=%d lists=%d, want 1/0", g, l) + } + if len(rows) != 3000 { + t.Fatalf("dirty List len=%d want 3000", len(rows)) + } + // Second read: mark cleared, zero backing cost. + backing.reset() + if _, err := store.List(ListQuery{Status: "open"}); err != nil { + t.Fatalf("second List: %v", err) + } + if g, l, _ := backing.counts(); g != 0 || l != 0 { + t.Fatalf("cleared List backing calls: gets=%d lists=%d, want 0/0", g, l) + } + + // cap dirty beads: exactly cap Gets, zero List. + for i := 0; i < dirtyOverlayMaxGets; i++ { + markDirtyForTest(store, ids[i]) + } + backing.reset() + if _, err := store.List(ListQuery{Status: "open"}); err != nil { + t.Fatalf("cap List: %v", err) + } + if g, l, _ := backing.counts(); g != dirtyOverlayMaxGets || l != 0 { + t.Fatalf("cap List backing calls: gets=%d lists=%d, want %d/0", g, l, dirtyOverlayMaxGets) + } + + // cap+1 dirty beads: fall back to exactly one backing.List, zero Gets. + for i := 0; i < dirtyOverlayMaxGets+1; i++ { + markDirtyForTest(store, ids[i]) + } + backing.reset() + if _, err := store.List(ListQuery{Status: "open"}); err != nil { + t.Fatalf("cap+1 List: %v", err) + } + if g, l, _ := backing.counts(); g != 0 || l != 1 { + t.Fatalf("cap+1 List backing calls: gets=%d lists=%d, want 0/1", g, l) + } +} + +// TestOverlayReadyPerfRoundTrip proves one dirty bead routes Ready through a +// single backing.Get, not a full backing.Ready scan. +func TestOverlayReadyPerfRoundTrip(t *testing.T) { + t.Parallel() + backing := &overlayCountingStore{Store: NewMemStore()} + var ids []string + for i := 0; i < 200; i++ { + created, err := backing.Create(Bead{Title: fmt.Sprintf("b%d", i), Status: "open"}) + if err != nil { + t.Fatalf("create: %v", err) + } + ids = append(ids, created.ID) + } + store := NewCachingStoreForTest(backing, nil) + if err := store.Prime(context.Background()); err != nil { + t.Fatalf("prime: %v", err) + } + newTitle := "changed" + if err := backing.Update(ids[0], UpdateOpts{Title: &newTitle}); err != nil { + t.Fatalf("update: %v", err) + } + markDirtyForTest(store, ids[0]) + backing.reset() + if _, err := store.Ready(); err != nil { + t.Fatalf("Ready: %v", err) + } + if g, _, r := backing.counts(); g != 1 || r != 0 { + t.Fatalf("1 dirty Ready backing calls: gets=%d readies=%d, want 1/0", g, r) + } +} + +// TestOverlayNotFoundSuppressed proves a dirty bead deleted from the backing is +// suppressed (omitted, matching what backing.List would return) and that each +// read pays exactly one bounded Get for it — never a full List. +func TestOverlayNotFoundSuppressed(t *testing.T) { + t.Parallel() + backing := &overlayCountingStore{Store: NewMemStore()} + keep, err := backing.Create(Bead{Title: "keep", Status: "open"}) + if err != nil { + t.Fatalf("create keep: %v", err) + } + gone, err := backing.Create(Bead{Title: "gone", Status: "open"}) + if err != nil { + t.Fatalf("create gone: %v", err) + } + store := NewCachingStoreForTest(backing, nil) + if err := store.Prime(context.Background()); err != nil { + t.Fatalf("prime: %v", err) + } + if err := backing.Delete(gone.ID); err != nil { + t.Fatalf("delete: %v", err) + } + markDirtyForTest(store, gone.ID) + + backing.reset() + rows, err := store.List(ListQuery{Status: "open"}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(rows) != 1 || rows[0].ID != keep.ID { + t.Fatalf("List = %v, want only %s", sortedIDs(rows), keep.ID) + } + if g, l, _ := backing.counts(); g != 1 || l != 0 { + t.Fatalf("suppressed List backing calls: gets=%d lists=%d, want 1/0", g, l) + } + // The ErrNotFound mark is deliberately left set (convergence stays with the + // reconciler), so a second read pays one bounded Get again, never a List. + backing.reset() + if _, err := store.List(ListQuery{Status: "open"}); err != nil { + t.Fatalf("second List: %v", err) + } + if g, l, _ := backing.counts(); g != 1 || l != 0 { + t.Fatalf("second suppressed List backing calls: gets=%d lists=%d, want 1/0", g, l) + } +} + +// TestOverlayFenceMidOverlayLocalWrite proves a local write that lands after +// the overlay snapshot is never clobbered by the fetched row (invariant I3): +// the read reflects the newer local state or falls back, never the pre-update +// fetched row. +func TestOverlayFenceMidOverlayLocalWrite(t *testing.T) { + t.Parallel() + backing := &overlayCountingStore{Store: NewMemStore()} + bead, err := backing.Create(Bead{Title: "orig", Status: "open"}) + if err != nil { + t.Fatalf("create: %v", err) + } + store := NewCachingStoreForTest(backing, nil) + if err := store.Prime(context.Background()); err != nil { + t.Fatalf("prime: %v", err) + } + // Backing carries a stale "fetched" value; the overlay Get returns it. + staleTitle := "stale-fetch" + if err := backing.Update(bead.ID, UpdateOpts{Title: &staleTitle}); err != nil { + t.Fatalf("update backing: %v", err) + } + markDirtyForTest(store, bead.ID) + + // When the overlay releases the lock to Get, a local write-through lands a + // newer value and re-marks the row dirty, bumping beadSeq past the snapshot. + // A plain atomic guard (not sync.Once, which is not reentrant) ensures the + // nested refresh-Get inside store.Update does not recurse into the mutation. + var fired atomic.Bool + backing.setGetHook(func(id string) { + if fired.Swap(true) { + return + } + newTitle := "local-newer" + if err := store.Update(id, UpdateOpts{Title: &newTitle}); err != nil { + t.Errorf("mid-overlay local write: %v", err) + } + }) + + rows, err := store.List(ListQuery{Status: "open"}) + backing.setGetHook(nil) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(rows) != 1 { + t.Fatalf("List len=%d want 1", len(rows)) + } + if rows[0].Title == "stale-fetch" { + t.Fatalf("overlay served the fenced-out fetched row %q (I3 violation)", rows[0].Title) + } + // Authoritative read must reflect the local write-through value. + got, err := store.Get(bead.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Title != "local-newer" { + t.Fatalf("Get title=%q want local-newer", got.Title) + } +} + +// TestOverlayMidOverlayDelete proves a mid-overlay delete+tombstone is honored: +// the row is omitted and the deletedSeq fence prevents resurrection (I3). +func TestOverlayMidOverlayDelete(t *testing.T) { + t.Parallel() + backing := &overlayCountingStore{Store: NewMemStore()} + keep, err := backing.Create(Bead{Title: "keep", Status: "open"}) + if err != nil { + t.Fatalf("create keep: %v", err) + } + victim, err := backing.Create(Bead{Title: "victim", Status: "open"}) + if err != nil { + t.Fatalf("create victim: %v", err) + } + store := NewCachingStoreForTest(backing, nil) + if err := store.Prime(context.Background()); err != nil { + t.Fatalf("prime: %v", err) + } + newTitle := "victim-changed" + if err := backing.Update(victim.ID, UpdateOpts{Title: &newTitle}); err != nil { + t.Fatalf("update: %v", err) + } + markDirtyForTest(store, victim.ID) + + var fired atomic.Bool + backing.setGetHook(func(id string) { + if id != victim.ID || fired.Swap(true) { + return + } + if err := store.Delete(victim.ID); err != nil { + t.Errorf("mid-overlay delete: %v", err) + } + }) + rows, err := store.List(ListQuery{Status: "open"}) + backing.setGetHook(nil) + if err != nil { + t.Fatalf("List: %v", err) + } + if ids := sortedIDs(rows); len(ids) != 1 || ids[0] != keep.ID { + t.Fatalf("List = %v, want only %s (deleted row must not resurrect)", ids, keep.ID) + } + if _, err := store.Get(victim.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("Get(victim) = %v, want ErrNotFound", err) + } +} + +// TestOverlayConcurrentHammer runs reads against writes under -race and checks +// no data race fires and reads stay internally consistent (I1/I7). +func TestOverlayConcurrentHammer(t *testing.T) { + t.Parallel() + backing := NewMemStore() + var ids []string + for i := 0; i < 40; i++ { + created, err := backing.Create(Bead{Title: fmt.Sprintf("b%d", i), Status: "open"}) + if err != nil { + t.Fatalf("create: %v", err) + } + ids = append(ids, created.ID) + } + store := NewCachingStoreForTest(backing, nil) + if err := store.Prime(context.Background()); err != nil { + t.Fatalf("prime: %v", err) + } + + var stop atomic.Bool + var wg sync.WaitGroup + deadline := time.Now().Add(750 * time.Millisecond) + + reader := func() { + defer wg.Done() + for !stop.Load() { + _, _ = store.List(ListQuery{Status: "open"}) + _, _ = store.Ready() + _, _ = store.Count(context.Background(), ListQuery{Status: "open"}) + if len(ids) > 0 { + _, _ = store.Get(ids[0]) + } + } + } + writer := func(seed int64) { + defer wg.Done() + rng := rand.New(rand.NewSource(seed)) + for !stop.Load() { + id := ids[rng.Intn(len(ids))] + title := fmt.Sprintf("w%d", rng.Intn(1000)) + _ = store.Update(id, UpdateOpts{Title: &title}) + markDirtyForTest(store, id) + } + } + + for i := 0; i < 4; i++ { + wg.Add(1) + go reader() + } + for i := 0; i < 3; i++ { + wg.Add(1) + go writer(int64(i + 1)) + } + for time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + stop.Store(true) + wg.Wait() + + // Read-your-writes probe (I1): after a settled write, the read paths must + // reflect it, never a known-stale row. + final := "final-value" + if err := store.Update(ids[0], UpdateOpts{Title: &final}); err != nil { + t.Fatalf("final update: %v", err) + } + got, err := store.Get(ids[0]) + if err != nil { + t.Fatalf("final Get: %v", err) + } + if got.Title != final { + t.Fatalf("final Get title=%q want %q", got.Title, final) + } +} + +// depStrippingStore mirrors the fork's flagship native DoltLite read store +// (internal/beads/doltlite_read_store.go): its Get and List return beads with +// NO Dependencies/Needs fields and no denormalized IsBlocked projection — those +// live in separate dependency tables the row snapshot does not carry — while +// DepList, dependencySnapshotForCache, and a blocking-aware Ready serve the +// authoritative deps. A cache that absorbs a dirty row from this backing with +// depsFromFields would clobber the cached deps to nil, serving a blocked bead as +// ready and making DepList return empty (gastownhall/gascity#2987 class). It is +// the shim that reproduces the exact R1 failure the overlay rework must close. +type depStrippingStore struct { + *MemStore +} + +func stripDepFields(b Bead) Bead { + b.Needs = nil + b.Dependencies = nil + b.IsBlocked = nil + return b +} + +func (s depStrippingStore) Get(id string) (Bead, error) { + b, err := s.MemStore.Get(id) + if err != nil { + return Bead{}, err + } + return stripDepFields(b), nil +} + +func (s depStrippingStore) List(query ListQuery) ([]Bead, error) { + rows, err := s.MemStore.List(query) + if err != nil { + return nil, err + } + for i := range rows { + rows[i] = stripDepFields(rows[i]) + } + return rows, nil +} + +func (s depStrippingStore) Count(_ context.Context, query ListQuery, excludeTypes ...string) (int, error) { + rows, err := s.MemStore.List(query) + if err != nil { + return 0, err + } + n := 0 + for _, b := range rows { + if slices.Contains(excludeTypes, b.Type) { + continue + } + n++ + } + return n, nil +} + +// Ready computes blocking via cachedBeadReady (IsBlocked is never carried by a +// DoltLite snapshot, so readiness falls to the dependency tables) and returns +// dep-stripped rows. Using the same readiness predicate the cache serves from — +// rather than MemStore.Ready, which treats a missing blocker as still blocking — +// keeps the clean twin a valid oracle across every dirty regime, including the +// cap+1 fallback where store.Ready delegates to backing.Ready. +func (s depStrippingStore) Ready(query ...ReadyQuery) ([]Bead, error) { + q := readyQueryFromArgs(query) + all, err := s.MemStore.List(ListQuery{AllowScan: true, IncludeClosed: true, TierMode: TierBoth}) + if err != nil { + return nil, err + } + statusByID := make(map[string]string, len(all)) + for _, b := range all { + statusByID[b.ID] = b.Status + } + now := time.Now().UTC() + var result []Bead + for _, b := range all { + if !IsReadyCandidateForTier(b, now, q.TierMode) { + continue + } + if q.Assignee != "" && b.Assignee != q.Assignee { + continue + } + deps, derr := s.DepList(b.ID, "down") + if derr != nil { + return nil, derr + } + if !cachedBeadReady(b, statusByID, deps) { + continue + } + result = append(result, stripDepFields(cloneBead(b))) + } + sortBeadsReadyOrder(result) + if q.Limit > 0 && len(result) > q.Limit { + result = result[:q.Limit] + } + return result, nil +} + +// dependencySnapshotForCache mirrors DoltliteReadStore: Prime (and thus the +// clean twin) sources complete deps here even though Get/List strip them. +func (s depStrippingStore) dependencySnapshotForCache(ids []string) (map[string][]Dep, bool, error) { + deps, err := s.DepListBatch(ids) + if err != nil { + return deps, false, err + } + return deps, true, nil +} + +func readyIDs(t *testing.T, store *CachingStore) []string { + t.Helper() + rows, err := store.Ready() + if err != nil { + t.Fatalf("Ready: %v", err) + } + return sortedIDs(rows) +} + +// TestOverlayDeplessBackingBlockedBeadNotServedReady is the deterministic proof +// that the overlay closes the R1 (#2987-class) regression on a backing whose +// Get carries no dependency fields: a blocked, dirty bead must never be served +// as ready and its DepList must never wrongly return empty after the overlay +// refresh. This test FAILS on the pre-rework overlay (depsFromFields clobbers +// c.deps[blocked] to nil) and passes once the overlay sources deps from an +// explicit backing.DepList for dep-less rows. +func TestOverlayDeplessBackingBlockedBeadNotServedReady(t *testing.T) { + t.Parallel() + backing := depStrippingStore{MemStore: NewMemStore()} + blocker, err := backing.Create(Bead{Title: "blocker", Status: "open"}) + if err != nil { + t.Fatalf("create blocker: %v", err) + } + blocked, err := backing.Create(Bead{Title: "blocked", Status: "open", Needs: []string{blocker.ID}}) + if err != nil { + t.Fatalf("create blocked: %v", err) + } + + store := NewCachingStoreForTest(backing, nil) + if err := store.Prime(context.Background()); err != nil { + t.Fatalf("prime: %v", err) + } + + // The clean cache must already exclude the blocked bead from Ready. + if ready := readyIDs(t, store); slices.Contains(ready, blocked.ID) { + t.Fatalf("clean cache served blocked bead as ready: %v", ready) + } + + // Mutate blocked in backing (title only — its deps are unchanged) and mark it + // dirty so the overlay refreshes it via backing.Get, which carries no dep + // fields. The overlay must NOT clobber blocked's cached deps to nil. + newTitle := "blocked-v2" + if err := backing.Update(blocked.ID, UpdateOpts{Title: &newTitle}); err != nil { + t.Fatalf("update: %v", err) + } + markDirtyForTest(store, blocked.ID) + + if ready := readyIDs(t, store); slices.Contains(ready, blocked.ID) { + t.Fatalf("R1 regression: dirty-overlay served blocked bead as ready (deps clobbered to nil): %v", ready) + } + deps, err := store.DepList(blocked.ID, "down") + if err != nil { + t.Fatalf("DepList: %v", err) + } + if len(deps) == 0 { + t.Fatalf("R1 regression: DepList(blocked) returned empty after dirty overlay") + } + assertDepsEquivalent(t, "blocked deps after overlay", deps, []Dep{{IssueID: blocked.ID, DependsOnID: blocker.ID, Type: "blocks"}}) + got, err := store.Get(blocked.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Title != newTitle { + t.Fatalf("overlay did not surface refreshed title: got %q want %q", got.Title, newTitle) + } +} + +// TestOverlayDeplessBackingReadEquivalenceDifferential is the R1 differential +// over a dep-less backing.Get shape (depStrippingStore). It complements the +// dep-carrying TestOverlayReadEquivalenceDifferential so T1 exercises BOTH +// backing shapes. Every overlay-served read (List/Ready/Count/DepList) must +// equal a clean-primed twin over the same backing, in every dirty regime — the +// blocked-bead deps clobber shows up as a Ready/DepList divergence here. +func TestOverlayDeplessBackingReadEquivalenceDifferential(t *testing.T) { + t.Parallel() + for _, n := range []int{1, 5, 50, 500} { + for _, k := range []int{0, 1, 2, dirtyOverlayMaxGets, dirtyOverlayMaxGets + 1} { + seed := int64(n*1000 + k) + t.Run(fmt.Sprintf("n%d_k%d", n, k), func(t *testing.T) { + runDeplessOverlayDifferential(t, seed, n, k) + }) + } + } +} + +func runDeplessOverlayDifferential(t *testing.T, seed int64, n, k int) { + t.Helper() + rng := rand.New(rand.NewSource(seed)) + backing := depStrippingStore{MemStore: NewMemStore()} + + statuses := []string{"open", "in_progress"} + labels := []string{"alpha", "beta", "gamma"} + assignees := []string{"", "ann", "bob"} + + var ids []string + for i := 0; i < n; i++ { + b := Bead{ + Title: fmt.Sprintf("bead-%d", i), + Status: statuses[rng.Intn(len(statuses))], + Assignee: assignees[rng.Intn(len(assignees))], + Labels: []string{labels[rng.Intn(len(labels))]}, + Metadata: map[string]string{"grp": fmt.Sprintf("g%d", rng.Intn(3))}, + } + // Roughly half the beads carry a blocking dependency on an earlier bead. + // Because the backing strips dep fields on Get, the overlay can only keep + // these blocked beads out of Ready by sourcing deps from backing.DepList. + if i > 0 && rng.Intn(2) == 0 { + b.Needs = []string{ids[rng.Intn(len(ids))]} + } + created, err := backing.Create(b) + if err != nil { + t.Fatalf("seed=%d create: %v", seed, err) + } + ids = append(ids, created.ID) + } + + store := NewCachingStoreForTest(backing, nil) + if err := store.Prime(context.Background()); err != nil { + t.Fatalf("seed=%d prime: %v", seed, err) + } + + // Drive a mixed dirty state. Bias the first dirty pick toward a bead that + // carries a dependency so a blocked+dirty row is exercised whenever one + // exists; the rest are random mutate/delete/fresh like the dep-carrying twin. + var dirtyIDs []string + for i := 0; i < k; i++ { + switch { + case len(ids) > 0 && rng.Intn(3) == 0: + id := ids[rng.Intn(len(ids))] + newTitle := fmt.Sprintf("mutated-%d", i) + newAssignee := assignees[rng.Intn(len(assignees))] + _ = backing.Update(id, UpdateOpts{Title: &newTitle, Assignee: &newAssignee}) + dirtyIDs = append(dirtyIDs, id) + case len(ids) > 0 && rng.Intn(2) == 0: + id := ids[rng.Intn(len(ids))] + _ = backing.Delete(id) + dirtyIDs = append(dirtyIDs, id) + default: + created, err := backing.Create(Bead{Title: fmt.Sprintf("fresh-%d", i), Status: "open"}) + if err != nil { + t.Fatalf("seed=%d create fresh: %v", seed, err) + } + dirtyIDs = append(dirtyIDs, created.ID) + } + } + markDirtyForTest(store, dirtyIDs...) + + twin := NewCachingStoreForTest(backing, nil) + if err := twin.Prime(context.Background()); err != nil { + t.Fatalf("seed=%d twin prime: %v", seed, err) + } + + queries := []ListQuery{ + {AllowScan: true, Sort: SortCreatedAsc}, + {Status: "open", Sort: SortCreatedAsc}, + {Label: "alpha", Sort: SortCreatedAsc}, + {Assignee: "ann", Sort: SortCreatedAsc}, + } + for i, q := range queries { + gotList, gotErr := store.List(q) + wantList, wantErr := twin.List(q) + if (gotErr == nil) != (wantErr == nil) { + t.Fatalf("seed=%d q%d List err got=%v want=%v", seed, i, gotErr, wantErr) + } + assertBeadsEquivalent(t, fmt.Sprintf("seed=%d q%d List", seed, i), gotList, wantList) + + gotCount, gErr := store.Count(context.Background(), q) + wantCount, wErr := twin.Count(context.Background(), q) + if (gErr == nil) != (wErr == nil) { + t.Fatalf("seed=%d q%d Count err got=%v want=%v", seed, i, gErr, wErr) + } + if gErr == nil && gotCount != wantCount { + t.Fatalf("seed=%d q%d Count got=%d want=%d", seed, i, gotCount, wantCount) + } + } + + // Ready is where the deps clobber surfaces: a blocked bead must stay out. + gotReady, err := store.Ready() + if err != nil { + t.Fatalf("seed=%d Ready: %v", seed, err) + } + wantReady, err := twin.Ready() + if err != nil { + t.Fatalf("seed=%d twin Ready: %v", seed, err) + } + assertBeadsEquivalent(t, fmt.Sprintf("seed=%d Ready", seed), gotReady, wantReady) + + // DepList equivalence after the overlay ran: a clobbered row would report an + // empty dep set where the twin still sees the blocking dependency. + allIDs := append(append([]string{}, ids...), dirtyIDs...) + for _, id := range allIDs { + gotDeps, gotErr := store.DepList(id, "down") + wantDeps, wantErr := twin.DepList(id, "down") + if (gotErr == nil) != (wantErr == nil) { + t.Fatalf("seed=%d DepList(%s) err got=%v want=%v", seed, id, gotErr, wantErr) + } + if gotErr == nil { + assertDepsEquivalent(t, fmt.Sprintf("seed=%d DepList(%s)", seed, id), gotDeps, wantDeps) + } + } +} + +// TestOverlayDeterministicPassTwoRetry proves the bounded retry deterministically +// absorbs a new dirty mark that lands on a DIFFERENT id mid-overlay: pass 1 +// fetches A, a mid-fetch write dirties B, and pass 2 absorbs B — with no +// fallback backing.List. This is the deterministic companion to the probabilistic +// concurrent hammer. +func TestOverlayDeterministicPassTwoRetry(t *testing.T) { + t.Parallel() + backing := &overlayCountingStore{Store: NewMemStore()} + a, err := backing.Create(Bead{Title: "a", Status: "open"}) + if err != nil { + t.Fatalf("create a: %v", err) + } + b, err := backing.Create(Bead{Title: "b", Status: "open"}) + if err != nil { + t.Fatalf("create b: %v", err) + } + store := NewCachingStoreForTest(backing, nil) + if err := store.Prime(context.Background()); err != nil { + t.Fatalf("prime: %v", err) + } + + ta, tb := "a-v2", "b-v2" + if err := backing.Update(a.ID, UpdateOpts{Title: &ta}); err != nil { + t.Fatalf("update a: %v", err) + } + if err := backing.Update(b.ID, UpdateOpts{Title: &tb}); err != nil { + t.Fatalf("update b: %v", err) + } + markDirtyForTest(store, a.ID) + + var fired atomic.Bool + backing.setGetHook(func(id string) { + if id != a.ID || fired.Swap(true) { + return + } + markDirtyForTest(store, b.ID) + }) + backing.reset() + rows, err := store.List(ListQuery{Status: "open"}) + backing.setGetHook(nil) + if err != nil { + t.Fatalf("List: %v", err) + } + got := beadIDSet(rows) + if got[a.ID].Title != ta || got[b.ID].Title != tb { + t.Fatalf("pass-2 did not absorb both refreshed rows: a=%q b=%q", got[a.ID].Title, got[b.ID].Title) + } + if g, l, _ := backing.counts(); l != 0 || g != 2 { + t.Fatalf("deterministic pass-2: want gets=2 lists=0, got gets=%d lists=%d", g, l) + } +} + +// TestOverlayChurnEveryPassFallsBack proves that when every pass introduces a +// fresh dirty mark on a new id, the bounded 2-pass overlay stops chasing churn +// and falls back to a single backing.List — never looping unboundedly. +func TestOverlayChurnEveryPassFallsBack(t *testing.T) { + t.Parallel() + backing := &overlayCountingStore{Store: NewMemStore()} + a, err := backing.Create(Bead{Title: "a", Status: "open"}) + if err != nil { + t.Fatalf("create a: %v", err) + } + var extras []string + for i := 0; i < 5; i++ { + e, err := backing.Create(Bead{Title: fmt.Sprintf("e%d", i), Status: "open"}) + if err != nil { + t.Fatalf("create extra: %v", err) + } + extras = append(extras, e.ID) + } + store := NewCachingStoreForTest(backing, nil) + if err := store.Prime(context.Background()); err != nil { + t.Fatalf("prime: %v", err) + } + ta := "a-v2" + if err := backing.Update(a.ID, UpdateOpts{Title: &ta}); err != nil { + t.Fatalf("update a: %v", err) + } + markDirtyForTest(store, a.ID) + + var idx atomic.Int32 + backing.setGetHook(func(_ string) { + i := int(idx.Add(1)) - 1 + if i < len(extras) { + markDirtyForTest(store, extras[i]) + } + }) + backing.reset() + rows, err := store.List(ListQuery{Status: "open"}) + backing.setGetHook(nil) + if err != nil { + t.Fatalf("List: %v", err) + } + if _, l, _ := backing.counts(); l == 0 { + t.Fatalf("expected fallback backing.List after churn on every pass, got lists=0") + } + if len(rows) != 6 { + t.Fatalf("fallback List len=%d want 6", len(rows)) + } +} + +// TestOverlaySuppressedResurrectionFence proves the symmetric fence for +// ErrNotFound-suppressed ids: if a concurrent event-apply resurrects a suppressed +// row as a live, clean bead between its fetch and the overlay's re-lock, the +// overlay must re-fetch it rather than omit it — otherwise the serve returns the +// cache MINUS a now-live row (a torn read). This test FAILS on the pre-rework +// overlay, which unconditionally omits every suppressed id. +func TestOverlaySuppressedResurrectionFence(t *testing.T) { + t.Parallel() + backing := &overlayCountingStore{Store: NewMemStore()} + keep, err := backing.Create(Bead{Title: "keep", Status: "open"}) + if err != nil { + t.Fatalf("create keep: %v", err) + } + ghost, err := backing.Create(Bead{Title: "ghost", Status: "open"}) + if err != nil { + t.Fatalf("create ghost: %v", err) + } + store := NewCachingStoreForTest(backing, nil) + if err := store.Prime(context.Background()); err != nil { + t.Fatalf("prime: %v", err) + } + + // Delete ghost from the backing and mark it dirty so the overlay Gets + // ErrNotFound and suppresses it. The mid-fetch hook then races the re-lock by + // recreating ghost in the backing AND applying a bead.updated event that + // re-installs it as a live, non-dirty cache row (bumping its mutation seq + // past the overlay snapshot) — the exact concurrent event-apply the symmetric + // fence must catch. + if err := backing.Delete(ghost.ID); err != nil { + t.Fatalf("delete ghost: %v", err) + } + markDirtyForTest(store, ghost.ID) + + mem := backing.Store.(*MemStore) + var fired atomic.Bool + backing.setGetHook(func(id string) { + if id != ghost.ID || fired.Swap(true) { + return + } + mem.mu.Lock() + mem.beads = append(mem.beads, Bead{ID: ghost.ID, Title: "ghost-reborn", Status: "open", Type: "task", CreatedAt: time.Now()}) + mem.mu.Unlock() + store.ApplyEvent("bead.updated", json.RawMessage(`{"id":"`+ghost.ID+`","title":"ghost-reborn"}`)) + }) + rows, err := store.List(ListQuery{Status: "open"}) + backing.setGetHook(nil) + if err != nil { + t.Fatalf("List: %v", err) + } + ids := sortedIDs(rows) + if !slices.Contains(ids, ghost.ID) { + t.Fatalf("suppressed-fence: resurrected row omitted from result (torn read): got %v, want it to include %s", ids, ghost.ID) + } + if !slices.Contains(ids, keep.ID) { + t.Fatalf("List dropped the untouched keep row: %v", ids) + } +} diff --git a/internal/beads/caching_store_reads.go b/internal/beads/caching_store_reads.go index 32bb69630d..ebebea19ce 100644 --- a/internal/beads/caching_store_reads.go +++ b/internal/beads/caching_store_reads.go @@ -28,29 +28,23 @@ func (c *CachingStore) List(query ListQuery) ([]Bead, error) { return items, err } - c.mu.RLock() - state := c.state - if state == cacheLive || state == cachePartial { - primePartialErr := c.primePartialErr - if len(c.dirty) > 0 { - c.mu.RUnlock() - return c.backing.List(liveListQuery(query)) - } - if primePartialErr != nil { - c.mu.RUnlock() - return c.backing.List(liveListQuery(query)) - } - // PrimeActive loads the full active set (open + in_progress), so - // active-only queries are complete even before the history prime finishes. - cached := make([]Bead, 0, len(c.beads)) + // Active-bead path: serve from cache after a bounded per-ID refresh of any + // dirty rows. PrimeActive loads the full active set (open + in_progress), + // so active-only queries are complete even before the history prime + // finishes. On overlay error the read takes the old full-scan fallback. + var cached []Bead + if err := c.readCacheWithOverlay(c.cacheServableLocked, func(suppressed map[string]struct{}) { + cached = make([]Bead, 0, len(c.beads)) for _, b := range c.beads { + if _, gone := suppressed[b.ID]; gone { + continue + } if !query.Matches(b) { continue } cached = append(cached, cloneBead(b)) } - c.mu.RUnlock() - + }); err == nil { finish := func(items []Bead, err error) ([]Bead, error) { sortBeadsForQuery(items, query.Sort) if query.Limit > 0 && len(items) > query.Limit { @@ -93,7 +87,6 @@ func (c *CachingStore) List(query ListQuery) ([]Bead, error) { } return finish(cached, err) } - c.mu.RUnlock() return c.backing.List(liveListQuery(query)) } @@ -118,20 +111,20 @@ func (c *CachingStore) Count(ctx context.Context, query ListQuery, excludeTypes return 0, fmt.Errorf("counting beads: %w", ErrCountUnsupported) } if !query.Live && query.ParentID == "" && !query.IncludesClosed() { - c.mu.RLock() - cacheClean := (c.state == cacheLive || c.state == cachePartial) && - len(c.dirty) == 0 && c.primePartialErr == nil - if cacheClean { - n := 0 + var n int + if err := c.readCacheWithOverlay(c.cacheServableLocked, func(suppressed map[string]struct{}) { + n = 0 for _, b := range c.beads { + if _, gone := suppressed[b.ID]; gone { + continue + } if query.Matches(b) && !slices.Contains(excludeTypes, b.Type) { n++ } } - c.mu.RUnlock() + }); err == nil { return n, nil } - c.mu.RUnlock() } counter, ok := c.backing.(Counter) if !ok { @@ -143,6 +136,13 @@ func (c *CachingStore) Count(ctx context.Context, query ListQuery, excludeTypes // CachedList returns query results from the in-memory cache only. The boolean // reports whether the cache was initialized and clean enough to answer without // touching the backing store. +// +// This strict cache-only handle intentionally keeps the conservative +// "dirty ⇒ decline" contract: it must answer without any backing I/O and +// without serving a row it is not certain matches the backing. The bounded +// per-ID dirty overlay (readCacheWithOverlay) applies only to the read paths +// that already fall back to the backing store (List/Count/Ready), where a +// refresh-and-serve is invisible to callers. func (c *CachingStore) CachedList(query ListQuery) ([]Bead, bool) { if query.IncludesClosed() { return nil, false @@ -425,52 +425,59 @@ func (c *CachingStore) Ready(query ...ReadyQuery) ([]Bead, error) { if readyQueryFromArgs(query) != (ReadyQuery{}) { return c.backing.Ready(query...) } - c.mu.RLock() - if c.state == cacheLive && c.depsComplete { - if len(c.dirty) > 0 { - c.mu.RUnlock() - return c.backing.Ready(query...) - } - if c.primePartialErr != nil { - c.mu.RUnlock() - return c.backing.Ready(query...) - } - statusByID := make(map[string]string, len(c.beads)) - depsByID := make(map[string][]Dep, len(c.deps)) - openBeads := make([]Bead, 0, len(c.beads)) - now := time.Now().UTC() - for _, b := range c.beads { - statusByID[b.ID] = b.Status - if IsReadyCandidate(b, now) { - openBeads = append(openBeads, cloneBead(b)) + var ( + statusByID map[string]string + depsByID map[string][]Dep + openBeads []Bead + ) + // Ready requires a fully live cache with complete dependency coverage; the + // overlay refreshes any dirty rows first, then computes readiness from the + // cache. On overlay error the read takes the old full backing.Ready scan. + if err := c.readCacheWithOverlay( + func() bool { return c.state == cacheLive && c.depsComplete && c.primePartialErr == nil }, + func(suppressed map[string]struct{}) { + statusByID = make(map[string]string, len(c.beads)) + openBeads = make([]Bead, 0, len(c.beads)) + now := time.Now().UTC() + for _, b := range c.beads { + if _, gone := suppressed[b.ID]; gone { + continue + } + statusByID[b.ID] = b.Status + if IsReadyCandidate(b, now) { + openBeads = append(openBeads, cloneBead(b)) + } } - } - for _, b := range openBeads { - deps := cloneDeps(c.deps[b.ID]) - depsByID[b.ID] = deps - } - c.mu.RUnlock() - - var result []Bead - for _, b := range openBeads { - if cachedBeadReady(b, statusByID, depsByID[b.ID]) { - result = append(result, cloneBead(b)) + depsByID = make(map[string][]Dep, len(openBeads)) + for _, b := range openBeads { + depsByID[b.ID] = cloneDeps(c.deps[b.ID]) } + }, + ); err != nil { + return c.backing.Ready(query...) + } + + var result []Bead + for _, b := range openBeads { + if cachedBeadReady(b, statusByID, depsByID[b.ID]) { + result = append(result, cloneBead(b)) } - // c.beads is a map, so the scan above yields a different order per - // call; impose the canonical ready order so cache-served results - // match the SQL-backed ready readers (#3208). - sortBeadsReadyOrder(result) - return result, nil } - c.mu.RUnlock() - return c.backing.Ready(query...) + // c.beads is a map, so the scan above yields a different order per + // call; impose the canonical ready order so cache-served results + // match the SQL-backed ready readers (#3208). + sortBeadsReadyOrder(result) + return result, nil } // CachedReady returns ready beads from the in-memory active read model. // The boolean reports whether the cache was initialized enough to answer // without touching the backing store. Unlike Ready, this can answer from a // partial active cache only when each open bead has known dependency coverage. +// +// Like CachedList, this strict cache-only handle keeps the conservative +// "dirty ⇒ decline" contract so a caller relying on cache-only semantics never +// observes a row refreshed behind its back or a stale ready candidate (#2210). func (c *CachingStore) CachedReady() ([]Bead, bool) { c.mu.RLock() defer c.mu.RUnlock() From 0061b41a682c802e6d2a6a48490b095fb56ac79c Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Thu, 9 Jul 2026 16:53:22 -0700 Subject: [PATCH 040/225] fix(reconciler): bind drain-ack poke seam on caller goroutine (kill cmd/gc CI flake) (#4117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `packages-cmd-gc-4-of-6` fails intermittently on `main` and PRs (it took down `main` on `e8f071037` + `simplify-land/s02`, and was flagged on #4064). The offender is **`TestQueueDrainAckAsyncStopTokenFenceSkipsReusedName`**, failing at 0.00s on `pokeCalls != 0`. ## Root cause (confirmed with `-race`) Cross-test contamination + data race on the package-global test seam `drainAckAsyncStopPokeController`: - `TestReconcileSessionBeads_DrainAckConsumesRestartRequested` drives the real reconcile path with a **nil** async-stop tracker, so the goroutine `queueDrainAckAsyncStop` spawns is undrainable and **leaks past the test**. - A later poke-counting test (e.g. `…TokenFenceSkipsReusedName`) swaps the package-global seam to a counting closure. - The leaked goroutine then **reads that global from a detached goroutine** (`session_reconciler.go:333`) — racing the swap (`-race`: write `session_reconciler_test.go` vs the goroutine read) and, on CI without `-race`, calling the *later* test's counter → `pokeCalls != 0` fires. Production is unaffected: the seam is assigned once at init and never swapped outside tests. ## Fix Bind the seam to a local on the **caller's** goroutine at queue time; the async goroutine calls the captured value. This confines every leaked goroutine to the seam that was live when *its* stop was queued (fixing all 76+ call sites at once), killing both the race and the counter contamination. No-op in production. `+8/-1`. ## Verification - `-race -count=60` on the drain-ack set (incl. the exact leaker+victim): **DATA RACE before → clean after**. - Siblings `…TokenFenceKillsMatchingSession`, `…ShutdownWaitsForTrackedAsyncDrainAckStops…`: `-race -count=50` → pass. - `go vet ./cmd/gc/` clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/gc/session_reconciler.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cmd/gc/session_reconciler.go b/cmd/gc/session_reconciler.go index 30e083a47f..16a4508163 100644 --- a/cmd/gc/session_reconciler.go +++ b/cmd/gc/session_reconciler.go @@ -296,6 +296,13 @@ func queueDrainAckAsyncStop(cityPath string, store beads.Store, sp runtime.Provi if !tracking { return } + // Bind the poke seam on the caller's goroutine, at queue time. The async + // goroutine below may outlive its reconcile invocation (see the poke + // comment), and re-reading the mutable package-global seam from a detached + // goroutine races with tests that swap it — and lets a goroutine queued by + // one test poke a later test's swapped-in counter. Capturing the value here + // confines each goroutine to the seam that was live when its stop was queued. + poke := drainAckAsyncStopPokeController go func() { defer func() { if r := recover(); r != nil { @@ -330,7 +337,7 @@ func queueDrainAckAsyncStop(cityPath string, store beads.Store, sp runtime.Provi // the caller's subsequent writes on the same writer (data race on // non-goroutine-safe buffers). The controller reconciles on the next // patrol tick regardless. - _ = drainAckAsyncStopPokeController(cityPath) + _ = poke(cityPath) }() } From 3d70a1ab8f3390187e7682fa4b078397e53a42b5 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Thu, 9 Jul 2026 17:05:52 -0700 Subject: [PATCH 041/225] feat(api): machine-readable error contract across the API (P12) (#4103) Every live Huma handler now returns machine-readable type/code errors via the internal/api/apierr registry, and every operation enumerates its error statuses (closed problem+json set, no catch-all default). Consolidates the former stacked series (registry pilot, mail, sessions, workflow, config, tails) plus the enumerate-all-ops follow-up and the rig-action typed enum into one branch. Client stays robust via a pdOf raw-body fallback. Adversarially red-teamed; full internal/api suite, spec-ci (zero drift), vet, docsync all green. --- cmd/gc/cmd_events.go | 20 +- docs/reference/api.md | 40 +- docs/reference/schema/openapi.json | 15782 +++++++++++++--- docs/reference/schema/openapi.txt | 15782 +++++++++++++--- engdocs/architecture/api-control-plane.md | 31 + engdocs/contributors/huma-usage.md | 28 + internal/api/apierr/apierr.go | 104 + internal/api/apierr/apierr_test.go | 164 + internal/api/apierr/catalog.go | 97 + internal/api/apierr/model.go | 59 + internal/api/apierr/testenv_import_test.go | 5 + internal/api/apierr_guard_test.go | 157 + internal/api/apierr_roundtrip_test.go | 150 + internal/api/cache_liveness.go | 4 +- internal/api/city_scope.go | 34 +- internal/api/client.go | 102 +- internal/api/client_test.go | 49 + internal/api/errors_install.go | 71 + internal/api/genclient/client_gen.go | 6939 ++++++- internal/api/handler_maintenance.go | 8 +- internal/api/handler_rigs_test.go | 21 +- internal/api/handler_sling_test.go | 15 +- internal/api/handler_webhook.go | 12 +- internal/api/huma_handlers_agents.go | 29 +- internal/api/huma_handlers_beads.go | 83 +- internal/api/huma_handlers_city.go | 12 +- internal/api/huma_handlers_convoys.go | 91 +- internal/api/huma_handlers_events.go | 15 +- internal/api/huma_handlers_extmsg.go | 52 +- internal/api/huma_handlers_formula_write.go | 5 +- internal/api/huma_handlers_formulas.go | 48 +- internal/api/huma_handlers_mail.go | 58 +- internal/api/huma_handlers_orders.go | 34 +- internal/api/huma_handlers_packs.go | 13 +- internal/api/huma_handlers_patches.go | 14 +- internal/api/huma_handlers_providers.go | 8 +- internal/api/huma_handlers_rigs.go | 9 +- internal/api/huma_handlers_services.go | 12 +- internal/api/huma_handlers_sessions.go | 35 +- .../api/huma_handlers_sessions_command.go | 132 +- internal/api/huma_handlers_sessions_query.go | 40 +- internal/api/huma_handlers_sessions_stream.go | 7 +- internal/api/huma_handlers_sling.go | 69 +- internal/api/huma_handlers_supervisor.go | 57 +- internal/api/huma_types_rigs.go | 2 +- internal/api/openapi.json | 15782 +++++++++++++--- internal/api/openapi_problem_types.go | 34 +- internal/api/partial_errors.go | 4 +- internal/api/supervisor_city_routes.go | 285 +- 49 files changed, 45837 insertions(+), 10767 deletions(-) create mode 100644 internal/api/apierr/apierr.go create mode 100644 internal/api/apierr/apierr_test.go create mode 100644 internal/api/apierr/catalog.go create mode 100644 internal/api/apierr/model.go create mode 100644 internal/api/apierr/testenv_import_test.go create mode 100644 internal/api/apierr_guard_test.go create mode 100644 internal/api/apierr_roundtrip_test.go create mode 100644 internal/api/errors_install.go diff --git a/cmd/gc/cmd_events.go b/cmd/gc/cmd_events.go index d9978f8378..42f708e838 100644 --- a/cmd/gc/cmd_events.go +++ b/cmd/gc/cmd_events.go @@ -897,7 +897,7 @@ func rotateCityEvents(ctx context.Context, client *genclient.ClientWithResponses if err != nil { return cliEventsRotateResponse{}, &eventsAPITransportError{err: err} } - if err := eventsListError(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := eventsListError(resp.StatusCode(), resp.Body); err != nil { return cliEventsRotateResponse{}, err } if resp.JSON200 == nil { @@ -959,7 +959,7 @@ func probeCityEventsReachable(ctx context.Context, client *genclient.ClientWithR if err != nil { return &eventsAPITransportError{err: err} } - return eventsListError(resp.StatusCode(), resp.ApplicationproblemJSONDefault) + return eventsListError(resp.StatusCode(), resp.Body) } func fetchCityEvents(ctx context.Context, client *genclient.ClientWithResponses, cityName, typeFilter, sinceFlag string) ([]cliWireEvent, error) { @@ -982,7 +982,7 @@ func fetchCityEvents(ctx context.Context, client *genclient.ClientWithResponses, if err != nil { return nil, &eventsAPITransportError{err: err} } - if err := eventsListError(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := eventsListError(resp.StatusCode(), resp.Body); err != nil { return nil, err } if resp.JSON200 == nil || resp.JSON200.Items == nil { @@ -1010,7 +1010,7 @@ func fetchCityHeadIndex(ctx context.Context, client *genclient.ClientWithRespons if err != nil { return "", &eventsAPITransportError{err: err} } - if err := eventsListError(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := eventsListError(resp.StatusCode(), resp.Body); err != nil { return "", err } if resp.HTTPResponse == nil { @@ -1047,7 +1047,7 @@ func fetchSupervisorEventsWithLimit(ctx context.Context, client *genclient.Clien if err != nil { return nil, fmt.Errorf("request failed: %w", err) } - if err := eventsListError(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := eventsListError(resp.StatusCode(), resp.Body); err != nil { return nil, err } if resp.JSON200 == nil || resp.JSON200.Items == nil { @@ -1088,13 +1088,19 @@ func fetchSupervisorHeadCursor(ctx context.Context, client *genclient.ClientWith return supervisorCursorFor(items), nil } -func eventsListError(statusCode int, problem *genclient.ErrorModel) error { +// eventsListError converts a non-2xx events response into a typed +// eventsAPIError. It reads the problem+json body directly from the raw +// response bytes rather than a generated per-status field: the events ops +// enumerate their error statuses (no catch-all `default` response), so the +// populated field varies by status, but the body is always an ErrorModel. +func eventsListError(statusCode int, body []byte) error { if statusCode >= 200 && statusCode < 300 { return nil } err := &eventsAPIError{statusCode: statusCode} - if problem != nil { + var problem genclient.ErrorModel + if len(body) > 0 && json.Unmarshal(body, &problem) == nil { if problem.Detail != nil { err.detail = strings.TrimSpace(*problem.Detail) } diff --git a/docs/reference/api.md b/docs/reference/api.md index 17a11c9c4c..dec7e81446 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -93,15 +93,37 @@ Each header's schema is documented in the operation's ## Errors Every error response is an RFC 9457 Problem Details body -(`application/problem+json`). Error types are documented in the spec -under `components.schemas.ErrorModel`. The `detail` field carries a -short `code: ` prefix (e.g. `pending_interaction: ...`, -`conflict: ...`, `not_found: ...`, `read_only: ...`) so clients can -pattern-match on the semantic code without needing a typed error -enum. Body-field validation errors (e.g. a required string posted -empty) come back as `422 Unprocessable Entity` or `400 Bad Request` -depending on the operation; the `errors` array of the Problem Details -body pinpoints which fields failed. +(`application/problem+json`), described by `components.schemas.ErrorModel`. + +**Branch on the machine-readable identity, not on prose.** An error carries a +stable `type` URN of the form `urn:gascity:error:` and a convenience +`code` member (the URN's final segment) — for example +`type: "urn:gascity:error:bead-not-found"`, `code: "bead-not-found"`. This is +the canonical identifier to switch on; it never changes between occurrences and +is independent of the human-readable `title`/`detail`. The full catalog of +codes the API can return is published in the spec as the +`x-gascity-problem-types` extension on the `ErrorModel.type` schema. + +The `detail` field remains a human-readable, occurrence-specific explanation. +Some legacy paths still encode a semantic hint as a `code: ` prefix on `detail` +(e.g. `not_found: ...`, `conflict: ...`, `read_only: ...`, `in_flight: ...`); +prefer the `type`/`code` members and treat detail-prefix parsing as +deprecated. An error whose body omits `code` is an as-yet-unconverted legacy +path — match it by `status` and `detail` until it gains a code. + +The framework's built-in request validation (e.g. a required string posted +empty, or `limit=-1`) carries `type: "urn:gascity:error:validation-failed"`, +usually as `422 Unprocessable Entity` — but as `400 Bad Request` for a body it +cannot parse and `415 Unsupported Media Type` for an unsupported content type; +the `code`/`type` is the constant across those statuses, and the `errors` array +pinpoints the fields that failed. A few endpoints perform their own additional +validation and return a code-less `400`/`422` until converted, so treat a +missing `code` as a legacy path (match on `status`/`detail`). Operations that +enumerate their error responses (currently the bead and sling endpoints) list +each status explicitly in the spec; others declare a single catch-all `default` +error response. An enumerated list covers the operation's own errors plus the +always-applied middleware errors (e.g. `403` on mutations); framework-level +transport statuses may still occur, as with any HTTP API. ## Streaming diff --git a/docs/reference/schema/openapi.json b/docs/reference/schema/openapi.json index 1f15a4bb4b..ddd26ef171 100644 --- a/docs/reference/schema/openapi.json +++ b/docs/reference/schema/openapi.json @@ -2112,6 +2112,10 @@ "ErrorModel": { "additionalProperties": false, "properties": { + "code": { + "description": "Stable machine-readable error code (the final segment of the type URN).", + "type": "string" + }, "detail": { "description": "A human-readable explanation specific to this occurrence of the problem.", "examples": [ @@ -2157,16 +2161,88 @@ "description": "A URI reference to human-readable documentation for the error.", "examples": [ "https://example.com/errors/example", - "urn:gascity:error:sling-missing-bead", + "urn:gascity:error:agent-not-found", + "urn:gascity:error:ambiguous-reference", + "urn:gascity:error:bad-gateway", + "urn:gascity:error:bead-not-found", + "urn:gascity:error:city-not-found", + "urn:gascity:error:conflict-concurrent-delete", + "urn:gascity:error:conflict-concurrent-modify", + "urn:gascity:error:conflict-wrong-state", + "urn:gascity:error:convoy-not-found", + "urn:gascity:error:extmsg-group-not-found", + "urn:gascity:error:forbidden", + "urn:gascity:error:formula-not-found", + "urn:gascity:error:gateway-timeout", + "urn:gascity:error:idempotency-in-flight", + "urn:gascity:error:idempotency-mismatch", + "urn:gascity:error:internal", + "urn:gascity:error:invalid-request", + "urn:gascity:error:mail-not-found", + "urn:gascity:error:method-not-allowed", + "urn:gascity:error:not-implemented", + "urn:gascity:error:operation-in-progress", + "urn:gascity:error:order-not-found", + "urn:gascity:error:pack-not-found", + "urn:gascity:error:patch-not-found", + "urn:gascity:error:provider-not-found", + "urn:gascity:error:rig-not-found", + "urn:gascity:error:scope-not-found", + "urn:gascity:error:service-not-found", + "urn:gascity:error:service-unavailable", + "urn:gascity:error:session-conflict", + "urn:gascity:error:session-not-found", "urn:gascity:error:sling-cross-rig", - "urn:gascity:error:sling-cross-store-route" + "urn:gascity:error:sling-cross-store-route", + "urn:gascity:error:sling-missing-bead", + "urn:gascity:error:sling-source-workflow-conflict", + "urn:gascity:error:store-unavailable", + "urn:gascity:error:validation-failed", + "urn:gascity:error:webhook-rejected", + "urn:gascity:error:workflow-not-found" ], "format": "uri", "type": "string", "x-gascity-problem-types": [ - "urn:gascity:error:sling-missing-bead", + "urn:gascity:error:agent-not-found", + "urn:gascity:error:ambiguous-reference", + "urn:gascity:error:bad-gateway", + "urn:gascity:error:bead-not-found", + "urn:gascity:error:city-not-found", + "urn:gascity:error:conflict-concurrent-delete", + "urn:gascity:error:conflict-concurrent-modify", + "urn:gascity:error:conflict-wrong-state", + "urn:gascity:error:convoy-not-found", + "urn:gascity:error:extmsg-group-not-found", + "urn:gascity:error:forbidden", + "urn:gascity:error:formula-not-found", + "urn:gascity:error:gateway-timeout", + "urn:gascity:error:idempotency-in-flight", + "urn:gascity:error:idempotency-mismatch", + "urn:gascity:error:internal", + "urn:gascity:error:invalid-request", + "urn:gascity:error:mail-not-found", + "urn:gascity:error:method-not-allowed", + "urn:gascity:error:not-implemented", + "urn:gascity:error:operation-in-progress", + "urn:gascity:error:order-not-found", + "urn:gascity:error:pack-not-found", + "urn:gascity:error:patch-not-found", + "urn:gascity:error:provider-not-found", + "urn:gascity:error:rig-not-found", + "urn:gascity:error:scope-not-found", + "urn:gascity:error:service-not-found", + "urn:gascity:error:service-unavailable", + "urn:gascity:error:session-conflict", + "urn:gascity:error:session-not-found", "urn:gascity:error:sling-cross-rig", - "urn:gascity:error:sling-cross-store-route" + "urn:gascity:error:sling-cross-store-route", + "urn:gascity:error:sling-missing-bead", + "urn:gascity:error:sling-source-workflow-conflict", + "urn:gascity:error:store-unavailable", + "urn:gascity:error:validation-failed", + "urn:gascity:error:webhook-rejected", + "urn:gascity:error:workflow-not-found" ] } }, @@ -17774,7 +17850,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -17782,7 +17858,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -17845,7 +17951,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -17853,7 +17959,97 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -17918,7 +18114,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -17926,7 +18122,112 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -17993,7 +18294,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -18001,7 +18302,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -18074,7 +18405,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -18082,31 +18413,136 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Patch v0 city by city name agent by base" - } - }, - "/v0/city/{cityName}/agent/{base}/output": { - "get": { - "operationId": "get-v0-city-by-city-name-agent-by-base-output", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Patch v0 city by city name agent by base" + } + }, + "/v0/city/{cityName}/agent/{base}/output": { + "get": { + "operationId": "get-v0-city-by-city-name-agent-by-base-output", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" } }, { @@ -18156,7 +18592,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -18164,7 +18600,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -18371,7 +18837,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -18379,7 +18845,97 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -18454,7 +19010,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -18462,38 +19018,143 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Delete v0 city by city name agent by dir by base" - }, - "get": { - "operationId": "get-v0-city-by-city-name-agent-by-dir-by-base", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "Agent directory (rig name).", - "in": "path", - "name": "dir", - "required": true, - "schema": { - "description": "Agent directory (rig name).", + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name agent by dir by base" + }, + "get": { + "operationId": "get-v0-city-by-city-name-agent-by-dir-by-base", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Agent directory (rig name).", + "in": "path", + "name": "dir", + "required": true, + "schema": { + "description": "Agent directory (rig name).", "type": "string" } }, @@ -18539,7 +19200,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -18547,7 +19208,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -18630,7 +19321,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -18638,7 +19329,112 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -18722,7 +19518,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -18730,7 +19526,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -18957,7 +19783,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -18965,14 +19791,104 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, "summary": "Post v0 city by city name agent by dir by base by action" } }, @@ -19088,7 +20004,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -19096,7 +20012,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -19160,7 +20106,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -19168,7 +20114,142 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "504": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Gateway Timeout", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -19233,7 +20314,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -19241,40 +20322,115 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Delete v0 city by city name bead by ID" - }, - "get": { - "operationId": "get-v0-city-by-city-name-bead-by-id", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Bead ID.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Bead ID.", - "type": "string" - } + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name bead by ID" + }, + "get": { + "operationId": "get-v0-city-by-city-name-bead-by-id", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Bead ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Bead ID.", + "type": "string" + } } ], "responses": { @@ -19308,7 +20464,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -19316,7 +20472,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -19389,7 +20590,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -19397,7 +20598,97 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -19490,7 +20781,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -19498,45 +20789,135 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name bead by ID assign" - } - }, - "/v0/city/{cityName}/bead/{id}/close": { - "post": { - "operationId": "post-v0-city-by-city-name-bead-by-id-close", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name bead by ID assign" + } + }, + "/v0/city/{cityName}/bead/{id}/close": { + "post": { + "operationId": "post-v0-city-by-city-name-bead-by-id-close", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { "description": "Bead ID.", "in": "path", "name": "id", @@ -19563,7 +20944,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -19571,7 +20952,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -19640,7 +21096,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -19648,7 +21104,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -19713,7 +21199,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -19721,82 +21207,29 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name bead by ID reopen" - } - }, - "/v0/city/{cityName}/bead/{id}/update": { - "post": { - "operationId": "post-v0-city-by-city-name-bead-by-id-update", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Bead ID.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Bead ID.", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BeadUpdateBody" - } - } }, - "required": true - }, - "responses": { - "200": { + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Forbidden", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -19804,13 +21237,231 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name bead by ID reopen" + } + }, + "/v0/city/{cityName}/bead/{id}/update": { + "post": { + "operationId": "post-v0-city-by-city-name-bead-by-id-update", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Bead ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Bead ID.", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BeadUpdateBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } }, "summary": "Post v0 city by city name bead by ID update" } @@ -19965,7 +21616,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -19973,7 +21624,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20060,7 +21756,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -20068,26 +21764,116 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Create a bead" - } - }, - "/v0/city/{cityName}/beads/graph/{rootID}": { - "get": { - "operationId": "get-v0-city-by-city-name-beads-graph-by-root-id", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Create a bead" + } + }, + "/v0/city/{cityName}/beads/graph/{rootID}": { + "get": { + "operationId": "get-v0-city-by-city-name-beads-graph-by-root-id", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, "schema": { "description": "City name.", "minLength": 1, @@ -20137,7 +21923,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -20145,7 +21931,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20224,7 +22040,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -20232,7 +22048,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20291,7 +22152,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -20299,7 +22160,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20358,7 +22249,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -20366,7 +22257,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20425,7 +22346,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -20433,7 +22354,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20477,7 +22428,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -20485,21 +22436,51 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name config validate" - } - }, - "/v0/city/{cityName}/convoy/{id}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-convoy-by-id", - "parameters": [ + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name config validate" + } + }, + "/v0/city/{cityName}/convoy/{id}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-convoy-by-id", + "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", "in": "header", @@ -20550,7 +22531,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -20558,7 +22539,82 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20625,7 +22681,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -20633,7 +22689,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20708,7 +22809,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -20716,7 +22817,82 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20785,7 +22961,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -20793,48 +22969,108 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name convoy by ID check" - } - }, - "/v0/city/{cityName}/convoy/{id}/close": { - "post": { - "operationId": "post-v0-city-by-city-name-convoy-by-id-close", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "Convoy ID.", - "in": "path", - "name": "id", + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name convoy by ID check" + } + }, + "/v0/city/{cityName}/convoy/{id}/close": { + "post": { + "operationId": "post-v0-city-by-city-name-convoy-by-id-close", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Convoy ID.", + "in": "path", + "name": "id", "required": true, "schema": { "description": "Convoy ID.", @@ -20858,7 +23094,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -20866,7 +23102,82 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20941,7 +23252,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -20949,7 +23260,82 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -21050,7 +23436,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -21058,7 +23444,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -21136,7 +23567,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -21144,17 +23575,92 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Create a convoy" - } - }, + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Create a convoy" + } + }, "/v0/city/{cityName}/events": { "get": { "operationId": "get-v0-city-by-city-name-events", @@ -21275,7 +23781,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -21283,7 +23789,52 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -21346,7 +23897,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -21354,7 +23905,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -21419,7 +24045,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -21427,7 +24053,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Method Not Allowed", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -21622,7 +24323,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -21630,21 +24331,96 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Delete v0 city by city name extmsg adapters" - }, - "get": { - "operationId": "get-v0-city-by-city-name-extmsg-adapters", - "parameters": [ - { - "description": "City name.", + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name extmsg adapters" + }, + "get": { + "operationId": "get-v0-city-by-city-name-extmsg-adapters", + "parameters": [ + { + "description": "City name.", "in": "path", "name": "cityName", "required": true, @@ -21687,7 +24463,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -21695,7 +24471,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -21758,7 +24579,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -21766,7 +24587,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -21831,7 +24727,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -21839,76 +24735,44 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name extmsg bind" - } - }, - "/v0/city/{cityName}/extmsg/bindings": { - "get": { - "operationId": "get-v0-city-by-city-name-extmsg-bindings", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Session ID to list bindings for.", - "explode": false, - "in": "query", - "name": "session_id", - "schema": { - "description": "Session ID to list bindings for.", - "type": "string" - } - } - ], - "responses": { - "200": { + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ListBodySessionBindingRecord" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unauthorized", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Forbidden", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -21916,20 +24780,217 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name extmsg bindings" - } - }, - "/v0/city/{cityName}/extmsg/groups": { - "get": { - "operationId": "get-v0-city-by-city-name-extmsg-groups", + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name extmsg bind" + } + }, + "/v0/city/{cityName}/extmsg/bindings": { + "get": { + "operationId": "get-v0-city-by-city-name-extmsg-bindings", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Session ID to list bindings for.", + "explode": false, + "in": "query", + "name": "session_id", + "schema": { + "description": "Session ID to list bindings for.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListBodySessionBindingRecord" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name extmsg bindings" + } + }, + "/v0/city/{cityName}/extmsg/groups": { + "get": { + "operationId": "get-v0-city-by-city-name-extmsg-groups", "parameters": [ { "description": "City name.", @@ -22010,7 +25071,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -22018,7 +25079,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22081,7 +25187,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -22089,72 +25195,59 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Ensure an external messaging group exists" - } - }, - "/v0/city/{cityName}/extmsg/inbound": { - "post": { - "operationId": "post-v0-city-by-city-name-extmsg-inbound", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExtMsgInboundInputBody" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "200": { + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/InboundResult" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -22162,7 +25255,22 @@ } } }, - "description": "Error", + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22170,12 +25278,12 @@ } } }, - "summary": "Post v0 city by city name extmsg inbound" + "summary": "Ensure an external messaging group exists" } }, - "/v0/city/{cityName}/extmsg/outbound": { + "/v0/city/{cityName}/extmsg/inbound": { "post": { - "operationId": "post-v0-city-by-city-name-extmsg-outbound", + "operationId": "post-v0-city-by-city-name-extmsg-inbound", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -22205,7 +25313,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExtMsgOutboundInputBody" + "$ref": "#/components/schemas/ExtMsgInboundInputBody" } } }, @@ -22216,7 +25324,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OutboundResult" + "$ref": "#/components/schemas/InboundResult" } } }, @@ -22227,7 +25335,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -22235,7 +25343,97 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22243,12 +25441,12 @@ } } }, - "summary": "Post v0 city by city name extmsg outbound" + "summary": "Post v0 city by city name extmsg inbound" } }, - "/v0/city/{cityName}/extmsg/participants": { - "delete": { - "operationId": "delete-v0-city-by-city-name-extmsg-participants", + "/v0/city/{cityName}/extmsg/outbound": { + "post": { + "operationId": "post-v0-city-by-city-name-extmsg-outbound", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -22278,7 +25476,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExtMsgParticipantRemoveInputBody" + "$ref": "#/components/schemas/ExtMsgOutboundInputBody" } } }, @@ -22289,7 +25487,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/OutboundResult" } } }, @@ -22300,7 +25498,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -22308,7 +25506,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22316,10 +25589,12 @@ } } }, - "summary": "Delete v0 city by city name extmsg participants" - }, - "post": { - "operationId": "post-v0-city-by-city-name-extmsg-participants", + "summary": "Post v0 city by city name extmsg outbound" + } + }, + "/v0/city/{cityName}/extmsg/participants": { + "delete": { + "operationId": "delete-v0-city-by-city-name-extmsg-participants", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -22349,7 +25624,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExtMsgParticipantUpsertInputBody" + "$ref": "#/components/schemas/ExtMsgParticipantRemoveInputBody" } } }, @@ -22360,7 +25635,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConversationGroupParticipant" + "$ref": "#/components/schemas/OKResponseBody" } } }, @@ -22371,7 +25646,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -22379,27 +25654,248 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name extmsg participants" - } - }, - "/v0/city/{cityName}/extmsg/transcript": { - "get": { - "operationId": "get-v0-city-by-city-name-extmsg-transcript", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name extmsg participants" + }, + "post": { + "operationId": "post-v0-city-by-city-name-extmsg-participants", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExtMsgParticipantUpsertInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationGroupParticipant" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name extmsg participants" + } + }, + "/v0/city/{cityName}/extmsg/transcript": { + "get": { + "operationId": "get-v0-city-by-city-name-extmsg-transcript", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { "description": "City name.", "minLength": 1, "pattern": "\\S", @@ -22534,7 +26030,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -22542,7 +26038,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22607,7 +26148,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -22615,7 +26156,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22680,7 +26296,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -22688,7 +26304,97 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22773,7 +26479,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -22781,7 +26487,67 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22845,7 +26611,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -22853,7 +26619,67 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22929,7 +26755,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -22937,7 +26763,67 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -23004,7 +26890,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -23012,7 +26898,97 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -23095,7 +27071,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -23103,7 +27079,67 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -23180,7 +27216,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -23188,82 +27224,44 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Put v0 city by city name formulas by name" - } - }, - "/v0/city/{cityName}/formulas/{name}/preview": { - "post": { - "operationId": "post-v0-city-by-city-name-formulas-by-name-preview", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Formula name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Formula name.", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FormulaPreviewBody" + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "200": { + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/FormulaDetailResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Forbidden", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -23271,95 +27269,29 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name formulas by name preview" - } - }, - "/v0/city/{cityName}/formulas/{name}/runs": { - "get": { - "operationId": "get-v0-city-by-city-name-formulas-by-name-runs", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Formula name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Formula name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Scope kind (city or rig).", - "explode": false, - "in": "query", - "name": "scope_kind", - "schema": { - "description": "Scope kind (city or rig).", - "type": "string" - } - }, - { - "description": "Scope reference.", - "explode": false, - "in": "query", - "name": "scope_ref", - "schema": { - "description": "Scope reference.", - "type": "string" - } - }, - { - "description": "Maximum number of recent runs to return. 0 = default.", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "description": "Maximum number of recent runs to return. 0 = default.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - } - ], - "responses": { - "200": { + "413": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/FormulaRunsResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Request Entity Too Large", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "422": { "content": { "application/problem+json": { "schema": { @@ -23367,63 +27299,29 @@ } } }, - "description": "Error", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name formulas by name runs" - } - }, - "/v0/city/{cityName}/formulas/{name}/source": { - "get": { - "operationId": "get-v0-city-by-city-name-formulas-by-name-source", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Formula name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Formula name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/FormulaSourceOutputBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "501": { "content": { "application/problem+json": { "schema": { @@ -23431,7 +27329,7 @@ } } }, - "description": "Error", + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -23439,12 +27337,12 @@ } } }, - "summary": "Get v0 city by city name formulas by name source" + "summary": "Put v0 city by city name formulas by name" } }, - "/v0/city/{cityName}/formulas/{name}/validate": { + "/v0/city/{cityName}/formulas/{name}/preview": { "post": { - "operationId": "post-v0-city-by-city-name-formulas-by-name-validate", + "operationId": "post-v0-city-by-city-name-formulas-by-name-preview", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -23476,19 +27374,15 @@ "required": true, "schema": { "description": "Formula name.", - "minLength": 1, - "pattern": "\\S", "type": "string" } } ], "requestBody": { "content": { - "application/octet-stream": { + "application/json": { "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" + "$ref": "#/components/schemas/FormulaPreviewBody" } } }, @@ -23499,7 +27393,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FormulaValidateOutputBody" + "$ref": "#/components/schemas/FormulaDetailResponse" } } }, @@ -23510,7 +27404,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -23518,51 +27412,44 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name formulas by name validate" - } - }, - "/v0/city/{cityName}/health": { - "get": { - "operationId": "get-v0-city-by-city-name-health", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } - } - ], - "responses": { - "200": { + }, + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/HealthOutputBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Forbidden", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -23570,7 +27457,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -23578,12 +27510,12 @@ } } }, - "summary": "Get v0 city by city name health" + "summary": "Post v0 city by city name formulas by name preview" } }, - "/v0/city/{cityName}/mail": { + "/v0/city/{cityName}/formulas/{name}/runs": { "get": { - "operationId": "get-v0-city-by-city-name-mail", + "operationId": "get-v0-city-by-city-name-formulas-by-name-runs", "parameters": [ { "description": "City name.", @@ -23598,76 +27530,48 @@ } }, { - "description": "Event sequence number; when provided, blocks until a newer event arrives.", - "explode": false, - "in": "query", - "name": "index", + "description": "Formula name.", + "in": "path", + "name": "name", + "required": true, "schema": { - "description": "Event sequence number; when provided, blocks until a newer event arrives.", + "description": "Formula name.", + "minLength": 1, + "pattern": "\\S", "type": "string" } }, { - "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", + "description": "Scope kind (city or rig).", "explode": false, "in": "query", - "name": "wait", + "name": "scope_kind", "schema": { - "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", + "description": "Scope kind (city or rig).", "type": "string" } }, { - "description": "Pagination cursor from a previous response's next_cursor field.", + "description": "Scope reference.", "explode": false, "in": "query", - "name": "cursor", + "name": "scope_ref", "schema": { - "description": "Pagination cursor from a previous response's next_cursor field.", + "description": "Scope reference.", "type": "string" } }, { - "description": "Maximum number of results to return. 0 = server default.", + "description": "Maximum number of recent runs to return. 0 = default.", "explode": false, "in": "query", "name": "limit", "schema": { - "description": "Maximum number of results to return. 0 = server default.", + "description": "Maximum number of recent runs to return. 0 = default.", "format": "int64", "minimum": 0, "type": "integer" } - }, - { - "description": "Filter by agent name.", - "explode": false, - "in": "query", - "name": "agent", - "schema": { - "description": "Filter by agent name.", - "type": "string" - } - }, - { - "description": "Filter by status (unread, all).", - "explode": false, - "in": "query", - "name": "status", - "schema": { - "description": "Filter by status (unread, all).", - "type": "string" - } - }, - { - "description": "Filter by rig name.", - "explode": false, - "in": "query", - "name": "rig", - "schema": { - "description": "Filter by rig name.", - "type": "string" - } } ], "responses": { @@ -23675,33 +27579,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MailListBody" + "$ref": "#/components/schemas/FormulaRunsResponse" } } }, "description": "OK", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -23709,94 +27598,59 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name mail" - }, - "post": { - "operationId": "send-mail", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Idempotency key for safe retries.", - "in": "header", - "name": "Idempotency-Key", - "schema": { - "description": "Idempotency key for safe retries.", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MailSendInputBody" + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "201": { + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Message" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "Created", + "description": "Unprocessable Entity", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Internal Server Error", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -23804,7 +27658,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -23812,12 +27666,12 @@ } } }, - "summary": "Send a mail message" + "summary": "Get v0 city by city name formulas by name runs" } }, - "/v0/city/{cityName}/mail/count": { + "/v0/city/{cityName}/formulas/{name}/source": { "get": { - "operationId": "get-v0-city-by-city-name-mail-count", + "operationId": "get-v0-city-by-city-name-formulas-by-name-source", "parameters": [ { "description": "City name.", @@ -23832,22 +27686,14 @@ } }, { - "description": "Filter by agent name.", - "explode": false, - "in": "query", - "name": "agent", - "schema": { - "description": "Filter by agent name.", - "type": "string" - } - }, - { - "description": "Filter by rig name.", - "explode": false, - "in": "query", - "name": "rig", + "description": "Formula name.", + "in": "path", + "name": "name", + "required": true, "schema": { - "description": "Filter by rig name.", + "description": "Formula name.", + "minLength": 1, + "pattern": "\\S", "type": "string" } } @@ -23857,25 +27703,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MailCountOutputBody" + "$ref": "#/components/schemas/FormulaSourceOutputBody" } } }, "description": "OK", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -23883,86 +27722,59 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name mail count" - } - }, - "/v0/city/{cityName}/mail/thread/{id}": { - "get": { - "operationId": "get-v0-city-by-city-name-mail-thread-by-id", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Thread ID, or any message ID in the thread.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Thread ID, or any message ID in the thread.", - "type": "string" - } }, - { - "description": "Filter by rig.", - "explode": false, - "in": "query", - "name": "rig", - "schema": { - "description": "Filter by rig.", - "type": "string" - } - } - ], - "responses": { - "200": { + "404": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/MailListBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Not Found", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Internal Server Error", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "501": { "content": { "application/problem+json": { "schema": { @@ -23970,7 +27782,7 @@ } } }, - "description": "Error", + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -23978,12 +27790,12 @@ } } }, - "summary": "Get v0 city by city name mail thread by ID" + "summary": "Get v0 city by city name formulas by name source" } }, - "/v0/city/{cityName}/mail/{id}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-mail-by-id", + "/v0/city/{cityName}/formulas/{name}/validate": { + "post": { + "operationId": "post-v0-city-by-city-name-formulas-by-name-validate", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -24009,32 +27821,36 @@ } }, { - "description": "Message ID.", + "description": "Formula name.", "in": "path", - "name": "id", + "name": "name", "required": true, "schema": { - "description": "Message ID.", - "type": "string" - } - }, - { - "description": "Rig hint.", - "explode": false, - "in": "query", - "name": "rig", - "schema": { - "description": "Rig hint.", + "description": "Formula name.", + "minLength": 1, + "pattern": "\\S", "type": "string" } } ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/FormulaValidateOutputBody" } } }, @@ -24045,7 +27861,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -24053,84 +27869,74 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Delete v0 city by city name mail by ID" - }, - "get": { - "operationId": "get-v0-city-by-city-name-mail-by-id", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Message ID.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Message ID.", - "type": "string" + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "Rig hint for O(1) lookup.", - "explode": false, - "in": "query", - "name": "rig", - "schema": { - "description": "Rig hint for O(1) lookup.", - "type": "string" - } - } - ], - "responses": { - "200": { + "404": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Message" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Not Found", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "413": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Request Entity Too Large", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Unprocessable Entity", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -24138,7 +27944,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24146,24 +27952,13 @@ } } }, - "summary": "Get v0 city by city name mail by ID" + "summary": "Post v0 city by city name formulas by name validate" } }, - "/v0/city/{cityName}/mail/{id}/archive": { - "post": { - "operationId": "post-v0-city-by-city-name-mail-by-id-archive", + "/v0/city/{cityName}/health": { + "get": { + "operationId": "get-v0-city-by-city-name-health", "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, { "description": "City name.", "in": "path", @@ -24175,26 +27970,6 @@ "pattern": "\\S", "type": "string" } - }, - { - "description": "Message ID.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Message ID.", - "type": "string" - } - }, - { - "description": "Rig hint.", - "explode": false, - "in": "query", - "name": "rig", - "schema": { - "description": "Rig hint.", - "type": "string" - } } ], "responses": { @@ -24202,7 +27977,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/HealthOutputBody" } } }, @@ -24213,7 +27988,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -24221,82 +27996,29 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name mail by ID archive" - } - }, - "/v0/city/{cityName}/mail/{id}/mark-unread": { - "post": { - "operationId": "post-v0-city-by-city-name-mail-by-id-mark-unread", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Message ID.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Message ID.", - "type": "string" - } - }, - { - "description": "Rig hint.", - "explode": false, - "in": "query", - "name": "rig", - "schema": { - "description": "Rig hint.", - "type": "string" - } - } - ], - "responses": { - "200": { + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -24304,7 +28026,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24312,24 +28034,13 @@ } } }, - "summary": "Post v0 city by city name mail by ID mark unread" + "summary": "Get v0 city by city name health" } }, - "/v0/city/{cityName}/mail/{id}/read": { - "post": { - "operationId": "post-v0-city-by-city-name-mail-by-id-read", + "/v0/city/{cityName}/mail": { + "get": { + "operationId": "get-v0-city-by-city-name-mail", "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, { "description": "City name.", "in": "path", @@ -24343,43 +28054,5342 @@ } }, { - "description": "Message ID.", - "in": "path", - "name": "id", - "required": true, + "description": "Event sequence number; when provided, blocks until a newer event arrives.", + "explode": false, + "in": "query", + "name": "index", "schema": { - "description": "Message ID.", + "description": "Event sequence number; when provided, blocks until a newer event arrives.", "type": "string" } }, { - "description": "Rig hint.", + "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", + "explode": false, + "in": "query", + "name": "wait", + "schema": { + "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", + "type": "string" + } + }, + { + "description": "Pagination cursor from a previous response's next_cursor field.", + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "description": "Pagination cursor from a previous response's next_cursor field.", + "type": "string" + } + }, + { + "description": "Maximum number of results to return. 0 = server default.", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "description": "Maximum number of results to return. 0 = server default.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Filter by agent name.", + "explode": false, + "in": "query", + "name": "agent", + "schema": { + "description": "Filter by agent name.", + "type": "string" + } + }, + { + "description": "Filter by status (unread, all).", + "explode": false, + "in": "query", + "name": "status", + "schema": { + "description": "Filter by status (unread, all).", + "type": "string" + } + }, + { + "description": "Filter by rig name.", "explode": false, "in": "query", "name": "rig", "schema": { - "description": "Rig hint.", + "description": "Filter by rig name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailListBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name mail" + }, + "post": { + "operationId": "send-mail", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Idempotency key for safe retries.", + "in": "header", + "name": "Idempotency-Key", + "schema": { + "description": "Idempotency key for safe retries.", "type": "string" } - } - ], - "responses": { - "200": { + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailSendInputBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + }, + "description": "Created", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Send a mail message" + } + }, + "/v0/city/{cityName}/mail/count": { + "get": { + "operationId": "get-v0-city-by-city-name-mail-count", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Filter by agent name.", + "explode": false, + "in": "query", + "name": "agent", + "schema": { + "description": "Filter by agent name.", + "type": "string" + } + }, + { + "description": "Filter by rig name.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Filter by rig name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailCountOutputBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name mail count" + } + }, + "/v0/city/{cityName}/mail/thread/{id}": { + "get": { + "operationId": "get-v0-city-by-city-name-mail-thread-by-id", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Thread ID, or any message ID in the thread.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Thread ID, or any message ID in the thread.", + "type": "string" + } + }, + { + "description": "Filter by rig.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Filter by rig.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailListBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name mail thread by ID" + } + }, + "/v0/city/{cityName}/mail/{id}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-mail-by-id", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Message ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Message ID.", + "type": "string" + } + }, + { + "description": "Rig hint.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Rig hint.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name mail by ID" + }, + "get": { + "operationId": "get-v0-city-by-city-name-mail-by-id", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Message ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Message ID.", + "type": "string" + } + }, + { + "description": "Rig hint for O(1) lookup.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Rig hint for O(1) lookup.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name mail by ID" + } + }, + "/v0/city/{cityName}/mail/{id}/archive": { + "post": { + "operationId": "post-v0-city-by-city-name-mail-by-id-archive", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Message ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Message ID.", + "type": "string" + } + }, + { + "description": "Rig hint.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Rig hint.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name mail by ID archive" + } + }, + "/v0/city/{cityName}/mail/{id}/mark-unread": { + "post": { + "operationId": "post-v0-city-by-city-name-mail-by-id-mark-unread", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Message ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Message ID.", + "type": "string" + } + }, + { + "description": "Rig hint.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Rig hint.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name mail by ID mark unread" + } + }, + "/v0/city/{cityName}/mail/{id}/read": { + "post": { + "operationId": "post-v0-city-by-city-name-mail-by-id-read", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Message ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Message ID.", + "type": "string" + } + }, + { + "description": "Rig hint.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Rig hint.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name mail by ID read" + } + }, + "/v0/city/{cityName}/mail/{id}/reply": { + "post": { + "operationId": "reply-mail", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Message ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Message ID.", + "type": "string" + } + }, + { + "description": "Rig hint.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Rig hint.", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailReplyInputBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + }, + "description": "Created", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Reply to a mail message" + } + }, + "/v0/city/{cityName}/maintenance/dolt-gc": { + "post": { + "description": "Trigger a one-off maintenance cycle (dolt backup + CALL DOLT_GC + smoke test). Default async (202); ?wait=true blocks until completion (200). Returns 409 when a run is already in flight.", + "operationId": "trigger-maintenance-dolt-gc", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "When true, the handler blocks until the run completes and returns 200 with the full Run. When false (default), the handler returns 202 Accepted immediately.", + "explode": false, + "in": "query", + "name": "wait", + "schema": { + "description": "When true, the handler blocks until the run completes and returns 200 with the full Run. When false (default), the handler returns 202 Accepted immediately.", + "type": "boolean" + } + } + ], + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceTriggerBody" + } + } + }, + "description": "Accepted", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Trigger a Dolt store maintenance run" + } + }, + "/v0/city/{cityName}/maintenance/status": { + "get": { + "operationId": "get-v0-city-by-city-name-maintenance-status", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceStatusBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "format": "double", + "type": "number" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name maintenance status" + } + }, + "/v0/city/{cityName}/order/history/{bead_id}": { + "get": { + "operationId": "get-v0-city-by-city-name-order-history-by-bead-id", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Bead ID for the order run.", + "in": "path", + "name": "bead_id", + "required": true, + "schema": { + "description": "Bead ID for the order run.", + "type": "string" + } + }, + { + "description": "Store reference for disambiguating store-local bead IDs.", + "explode": false, + "in": "query", + "name": "store_ref", + "schema": { + "description": "Store reference for disambiguating store-local bead IDs.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderHistoryDetailResponse" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name order history by bead ID" + } + }, + "/v0/city/{cityName}/order/{name}": { + "get": { + "operationId": "get-v0-city-by-city-name-order-by-name", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Order name or scoped name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Order name or scoped name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderResponse" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name order by name" + } + }, + "/v0/city/{cityName}/order/{name}/disable": { + "post": { + "operationId": "post-v0-city-by-city-name-order-by-name-disable", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Order name or scoped name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Order name or scoped name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name order by name disable" + } + }, + "/v0/city/{cityName}/order/{name}/enable": { + "post": { + "operationId": "post-v0-city-by-city-name-order-by-name-enable", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Order name or scoped name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Order name or scoped name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name order by name enable" + } + }, + "/v0/city/{cityName}/order/{name}/run": { + "post": { + "operationId": "post-v0-city-by-city-name-order-by-name-run", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Order name or scoped name of a trigger=\"webhook\" order.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Order name or scoped name of a trigger=\"webhook\" order.", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderRunInputBody" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderRunOutputBody" + } + } + }, + "description": "Accepted", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name order by name run" + } + }, + "/v0/city/{cityName}/orders": { + "get": { + "operationId": "get-v0-city-by-city-name-orders", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderListBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name orders" + } + }, + "/v0/city/{cityName}/orders/check": { + "get": { + "operationId": "get-v0-city-by-city-name-orders-check", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Bypass cached order-check responses and cached order history.", + "explode": false, + "in": "query", + "name": "fresh", + "schema": { + "description": "Bypass cached order-check responses and cached order history.", + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderCheckListBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name orders check" + } + }, + "/v0/city/{cityName}/orders/feed": { + "get": { + "operationId": "get-v0-city-by-city-name-orders-feed", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Scope kind (city or rig).", + "explode": false, + "in": "query", + "name": "scope_kind", + "schema": { + "description": "Scope kind (city or rig).", + "type": "string" + } + }, + { + "description": "Scope reference.", + "explode": false, + "in": "query", + "name": "scope_ref", + "schema": { + "description": "Scope reference.", + "type": "string" + } + }, + { + "description": "Maximum number of feed items to return.", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "description": "Maximum number of feed items to return.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrdersFeedBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name orders feed" + } + }, + "/v0/city/{cityName}/orders/history": { + "get": { + "operationId": "get-v0-city-by-city-name-orders-history", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Scoped order name.", + "explode": false, + "in": "query", + "name": "scoped_name", + "required": true, + "schema": { + "description": "Scoped order name.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "Maximum number of history entries. 0 = default.", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "description": "Maximum number of history entries. 0 = default.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Return entries before this RFC3339 timestamp.", + "explode": false, + "in": "query", + "name": "before", + "schema": { + "description": "Return entries before this RFC3339 timestamp.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderHistoryListBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name orders history" + } + }, + "/v0/city/{cityName}/packs": { + "get": { + "operationId": "get-v0-city-by-city-name-packs", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackListBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name packs" + }, + "post": { + "description": "Imports a pack into the city by source (a remote git URL or registry ref), resolving + installing it so its templates compose into the city.", + "operationId": "add-pack", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackAddInputBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackAddedOutputBody" + } + } + }, + "description": "Created", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "502": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Gateway", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Add a pack" + } + }, + "/v0/city/{cityName}/packs/{name}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-packs-by-name", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "The import binding name to remove (the [imports.\u003cname\u003e] key).", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "The import binding name to remove (the [imports.\u003cname\u003e] key).", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackRemovedOutputBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name packs by name" + } + }, + "/v0/city/{cityName}/patches/agent/{base}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-patches-agent-by-base", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Agent patch name (unqualified).", + "in": "path", + "name": "base", + "required": true, + "schema": { + "description": "Agent patch name (unqualified).", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchDeletedResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name patches agent by base" + }, + "get": { + "operationId": "get-v0-city-by-city-name-patches-agent-by-base", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Agent patch name (unqualified).", + "in": "path", + "name": "base", + "required": true, + "schema": { + "description": "Agent patch name (unqualified).", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentPatch" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name patches agent by base" + } + }, + "/v0/city/{cityName}/patches/agent/{dir}/{base}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-patches-agent-by-dir-by-base", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Agent directory (rig name).", + "in": "path", + "name": "dir", + "required": true, + "schema": { + "description": "Agent directory (rig name).", + "type": "string" + } + }, + { + "description": "Agent base name.", + "in": "path", + "name": "base", + "required": true, + "schema": { + "description": "Agent base name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchDeletedResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name patches agent by dir by base" + }, + "get": { + "operationId": "get-v0-city-by-city-name-patches-agent-by-dir-by-base", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Agent directory (rig name).", + "in": "path", + "name": "dir", + "required": true, + "schema": { + "description": "Agent directory (rig name).", + "type": "string" + } + }, + { + "description": "Agent base name.", + "in": "path", + "name": "base", + "required": true, + "schema": { + "description": "Agent base name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentPatch" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name patches agent by dir by base" + } + }, + "/v0/city/{cityName}/patches/agents": { + "get": { + "operationId": "get-v0-city-by-city-name-patches-agents", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListBodyAgentPatch" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name patches agents" + }, + "put": { + "operationId": "put-v0-city-by-city-name-patches-agents", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentPatchSetInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchOKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Put v0 city by city name patches agents" + } + }, + "/v0/city/{cityName}/patches/provider/{name}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-patches-provider-by-name", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Provider patch name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Provider patch name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchDeletedResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name patches provider by name" + }, + "get": { + "operationId": "get-v0-city-by-city-name-patches-provider-by-name", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Provider patch name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Provider patch name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderPatch" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name patches provider by name" + } + }, + "/v0/city/{cityName}/patches/providers": { + "get": { + "operationId": "get-v0-city-by-city-name-patches-providers", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListBodyProviderPatch" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name patches providers" + }, + "put": { + "operationId": "put-v0-city-by-city-name-patches-providers", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderPatchSetInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchOKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Put v0 city by city name patches providers" + } + }, + "/v0/city/{cityName}/patches/rig/{name}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-patches-rig-by-name", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Rig patch name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Rig patch name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchDeletedResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name patches rig by name" + }, + "get": { + "operationId": "get-v0-city-by-city-name-patches-rig-by-name", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Rig patch name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Rig patch name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RigPatch" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name patches rig by name" + } + }, + "/v0/city/{cityName}/patches/rigs": { + "get": { + "operationId": "get-v0-city-by-city-name-patches-rigs", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListBodyRigPatch" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name patches rigs" + }, + "put": { + "operationId": "put-v0-city-by-city-name-patches-rigs", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RigPatchSetInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchOKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Put v0 city by city name patches rigs" + } + }, + "/v0/city/{cityName}/pending": { + "get": { + "operationId": "get-v0-city-by-city-name-pending", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListBodyCityPendingEntry" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -24387,7 +33397,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24395,24 +33405,13 @@ } } }, - "summary": "Post v0 city by city name mail by ID read" + "summary": "Get v0 city by city name pending" } }, - "/v0/city/{cityName}/mail/{id}/reply": { - "post": { - "operationId": "reply-mail", + "/v0/city/{cityName}/provider-readiness": { + "get": { + "operationId": "get-v0-city-by-city-name-provider-readiness", "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, { "description": "City name.", "in": "path", @@ -24426,68 +33425,88 @@ } }, { - "description": "Message ID.", - "in": "path", - "name": "id", - "required": true, + "description": "Comma-separated provider names to check (default: claude,codex,gemini).", + "explode": false, + "in": "query", + "name": "providers", "schema": { - "description": "Message ID.", + "description": "Comma-separated provider names to check (default: claude,codex,gemini).", "type": "string" } }, { - "description": "Rig hint.", + "description": "Force fresh probe, bypassing cache.", "explode": false, "in": "query", - "name": "rig", + "name": "fresh", "schema": { - "description": "Rig hint.", - "type": "string" + "description": "Force fresh probe, bypassing cache.", + "type": "boolean" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MailReplyInputBody" + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderReadinessResponse" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "201": { + "400": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Message" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "Created", + "description": "Bad Request", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Unprocessable Entity", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -24495,7 +33514,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24503,13 +33522,12 @@ } } }, - "summary": "Reply to a mail message" + "summary": "Get v0 city by city name provider readiness" } }, - "/v0/city/{cityName}/maintenance/dolt-gc": { - "post": { - "description": "Trigger a one-off maintenance cycle (dolt backup + CALL DOLT_GC + smoke test). Default async (202); ?wait=true blocks until completion (200). Returns 409 when a run is already in flight.", - "operationId": "trigger-maintenance-dolt-gc", + "/v0/city/{cityName}/provider/{name}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-provider-by-name", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -24535,33 +33553,33 @@ } }, { - "description": "When true, the handler blocks until the run completes and returns 200 with the full Run. When false (default), the handler returns 202 Accepted immediately.", - "explode": false, - "in": "query", - "name": "wait", + "description": "Provider name.", + "in": "path", + "name": "name", + "required": true, "schema": { - "description": "When true, the handler blocks until the run completes and returns 200 with the full Run. When false (default), the handler returns 202 Accepted immediately.", - "type": "boolean" + "description": "Provider name.", + "type": "string" } } ], "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceTriggerBody" + "$ref": "#/components/schemas/OKResponseBody" } } }, - "description": "Accepted", + "description": "OK", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -24569,57 +33587,44 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Trigger a Dolt store maintenance run" - } - }, - "/v0/city/{cityName}/maintenance/status": { - "get": { - "operationId": "get-v0-city-by-city-name-maintenance-status", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { + }, + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/MaintenanceStatusBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unauthorized", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { "schema": { - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Forbidden", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -24627,71 +33632,59 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name maintenance status" - } - }, - "/v0/city/{cityName}/order/history/{bead_id}": { - "get": { - "operationId": "get-v0-city-by-city-name-order-history-by-bead-id", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Bead ID for the order run.", - "in": "path", - "name": "bead_id", - "required": true, - "schema": { - "description": "Bead ID for the order run.", - "type": "string" + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "Store reference for disambiguating store-local bead IDs.", - "explode": false, - "in": "query", - "name": "store_ref", - "schema": { - "description": "Store reference for disambiguating store-local bead IDs.", - "type": "string" + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } - } - ], - "responses": { - "200": { + }, + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OrderHistoryDetailResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "501": { "content": { "application/problem+json": { "schema": { @@ -24699,7 +33692,7 @@ } } }, - "description": "Error", + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24707,12 +33700,10 @@ } } }, - "summary": "Get v0 city by city name order history by bead ID" - } - }, - "/v0/city/{cityName}/order/{name}": { + "summary": "Delete v0 city by city name provider by name" + }, "get": { - "operationId": "get-v0-city-by-city-name-order-by-name", + "operationId": "get-v0-city-by-city-name-provider-by-name", "parameters": [ { "description": "City name.", @@ -24727,12 +33718,12 @@ } }, { - "description": "Order name or scoped name.", + "description": "Provider name.", "in": "path", "name": "name", "required": true, "schema": { - "description": "Order name or scoped name.", + "description": "Provider name.", "type": "string" } } @@ -24742,18 +33733,33 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrderResponse" + "$ref": "#/components/schemas/ProviderResponse" } } }, "description": "OK", "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -24761,7 +33767,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24769,12 +33805,10 @@ } } }, - "summary": "Get v0 city by city name order by name" - } - }, - "/v0/city/{cityName}/order/{name}/disable": { - "post": { - "operationId": "post-v0-city-by-city-name-order-by-name-disable", + "summary": "Get v0 city by city name provider by name" + }, + "patch": { + "operationId": "patch-v0-city-by-city-name-provider-by-name", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -24800,16 +33834,26 @@ } }, { - "description": "Order name or scoped name.", + "description": "Provider name.", "in": "path", "name": "name", "required": true, "schema": { - "description": "Order name or scoped name.", + "description": "Provider name.", "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderUpdateInputBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { @@ -24826,7 +33870,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -24834,7 +33878,112 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24842,24 +33991,13 @@ } } }, - "summary": "Post v0 city by city name order by name disable" + "summary": "Patch v0 city by city name provider by name" } }, - "/v0/city/{cityName}/order/{name}/enable": { - "post": { - "operationId": "post-v0-city-by-city-name-order-by-name-enable", + "/v0/city/{cityName}/providers": { + "get": { + "operationId": "get-v0-city-by-city-name-providers", "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, { "description": "City name.", "in": "path", @@ -24871,16 +34009,6 @@ "pattern": "\\S", "type": "string" } - }, - { - "description": "Order name or scoped name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Order name or scoped name.", - "type": "string" - } } ], "responses": { @@ -24888,18 +34016,33 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ListBodyProviderResponse" } } }, "description": "OK", "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -24907,7 +34050,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24915,12 +34088,10 @@ } } }, - "summary": "Post v0 city by city name order by name enable" - } - }, - "/v0/city/{cityName}/order/{name}/run": { + "summary": "Get v0 city by city name providers" + }, "post": { - "operationId": "post-v0-city-by-city-name-order-by-name-run", + "operationId": "create-provider", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -24944,45 +34115,35 @@ "pattern": "\\S", "type": "string" } - }, - { - "description": "Order name or scoped name of a trigger=\"webhook\" order.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Order name or scoped name of a trigger=\"webhook\" order.", - "type": "string" - } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrderRunInputBody" + "$ref": "#/components/schemas/ProviderCreateInputBody" } } }, "required": true }, "responses": { - "202": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrderRunOutputBody" + "$ref": "#/components/schemas/ProviderCreatedOutputBody" } } }, - "description": "Accepted", + "description": "Created", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -24990,51 +34151,29 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name order by name run" - } - }, - "/v0/city/{cityName}/orders": { - "get": { - "operationId": "get-v0-city-by-city-name-orders", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { + }, + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OrderListBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "403": { "content": { "application/problem+json": { "schema": { @@ -25042,61 +34181,29 @@ } } }, - "description": "Error", + "description": "Forbidden", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name orders" - } - }, - "/v0/city/{cityName}/orders/check": { - "get": { - "operationId": "get-v0-city-by-city-name-orders-check", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Bypass cached order-check responses and cached order history.", - "explode": false, - "in": "query", - "name": "fresh", - "schema": { - "description": "Bypass cached order-check responses and cached order history.", - "type": "boolean" - } - } - ], - "responses": { - "200": { + "404": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OrderCheckListBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "409": { "content": { "application/problem+json": { "schema": { @@ -25104,83 +34211,44 @@ } } }, - "description": "Error", + "description": "Conflict", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name orders check" - } - }, - "/v0/city/{cityName}/orders/feed": { - "get": { - "operationId": "get-v0-city-by-city-name-orders-feed", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Scope kind (city or rig).", - "explode": false, - "in": "query", - "name": "scope_kind", - "schema": { - "description": "Scope kind (city or rig).", - "type": "string" - } }, - { - "description": "Scope reference.", - "explode": false, - "in": "query", - "name": "scope_ref", - "schema": { - "description": "Scope reference.", - "type": "string" + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "Maximum number of feed items to return.", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "description": "Maximum number of feed items to return.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - } - ], - "responses": { - "200": { + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OrdersFeedBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "501": { "content": { "application/problem+json": { "schema": { @@ -25188,7 +34256,7 @@ } } }, - "description": "Error", + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -25196,12 +34264,12 @@ } } }, - "summary": "Get v0 city by city name orders feed" + "summary": "Create a provider" } }, - "/v0/city/{cityName}/orders/history": { + "/v0/city/{cityName}/providers/public": { "get": { - "operationId": "get-v0-city-by-city-name-orders-history", + "operationId": "get-v0-city-by-city-name-providers-public", "parameters": [ { "description": "City name.", @@ -25214,40 +34282,6 @@ "pattern": "\\S", "type": "string" } - }, - { - "description": "Scoped order name.", - "explode": false, - "in": "query", - "name": "scoped_name", - "required": true, - "schema": { - "description": "Scoped order name.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "Maximum number of history entries. 0 = default.", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "description": "Maximum number of history entries. 0 = default.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, - { - "description": "Return entries before this RFC3339 timestamp.", - "explode": false, - "in": "query", - "name": "before", - "schema": { - "description": "Return entries before this RFC3339 timestamp.", - "type": "string" - } } ], "responses": { @@ -25255,17 +34289,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrderHistoryListBody" + "$ref": "#/components/schemas/ProviderPublicListBody" } } }, "description": "OK", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Index": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" } }, "X-GC-Request-Id": { @@ -25273,7 +34308,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -25281,7 +34316,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -25289,12 +34354,12 @@ } } }, - "summary": "Get v0 city by city name orders history" + "summary": "Get v0 city by city name providers public" } }, - "/v0/city/{cityName}/packs": { + "/v0/city/{cityName}/readiness": { "get": { - "operationId": "get-v0-city-by-city-name-packs", + "operationId": "get-v0-city-by-city-name-readiness", "parameters": [ { "description": "City name.", @@ -25302,11 +34367,31 @@ "name": "cityName", "required": true, "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Comma-separated readiness items to check (default: claude,codex,gemini,github_cli).", + "explode": false, + "in": "query", + "name": "items", + "schema": { + "description": "Comma-separated readiness items to check (default: claude,codex,gemini,github_cli).", "type": "string" } + }, + { + "description": "Force fresh probe, bypassing cache.", + "explode": false, + "in": "query", + "name": "fresh", + "schema": { + "description": "Force fresh probe, bypassing cache.", + "type": "boolean" + } } ], "responses": { @@ -25314,7 +34399,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackListBody" + "$ref": "#/components/schemas/ReadinessResponse" } } }, @@ -25325,7 +34410,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -25333,71 +34418,44 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name packs" - }, - "post": { - "description": "Imports a pack into the city by source (a remote git URL or registry ref), resolving + installing it so its templates compose into the city.", - "operationId": "add-pack", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PackAddInputBody" + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "201": { + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/PackAddedOutputBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "Created", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -25405,7 +34463,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -25413,12 +34471,12 @@ } } }, - "summary": "Add a pack" + "summary": "Get v0 city by city name readiness" } }, - "/v0/city/{cityName}/packs/{name}": { + "/v0/city/{cityName}/rig/{name}": { "delete": { - "operationId": "delete-v0-city-by-city-name-packs-by-name", + "operationId": "delete-v0-city-by-city-name-rig-by-name", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -25444,12 +34502,12 @@ } }, { - "description": "The import binding name to remove (the [imports.\u003cname\u003e] key).", + "description": "Rig name.", "in": "path", "name": "name", "required": true, "schema": { - "description": "The import binding name to remove (the [imports.\u003cname\u003e] key).", + "description": "Rig name.", "type": "string" } } @@ -25459,7 +34517,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackRemovedOutputBody" + "$ref": "#/components/schemas/OKResponseBody" } } }, @@ -25470,7 +34528,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -25478,72 +34536,29 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Delete v0 city by city name packs by name" - } - }, - "/v0/city/{cityName}/patches/agent/{base}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-patches-agent-by-base", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Agent patch name (unqualified).", - "in": "path", - "name": "base", - "required": true, - "schema": { - "description": "Agent patch name (unqualified).", - "type": "string" - } - } - ], - "responses": { - "200": { + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/PatchDeletedResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "403": { "content": { "application/problem+json": { "schema": { @@ -25551,74 +34566,29 @@ } } }, - "description": "Error", + "description": "Forbidden", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Delete v0 city by city name patches agent by base" - }, - "get": { - "operationId": "get-v0-city-by-city-name-patches-agent-by-base", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Agent patch name (unqualified).", - "in": "path", - "name": "base", - "required": true, - "schema": { - "description": "Agent patch name (unqualified).", - "type": "string" - } - } - ], - "responses": { - "200": { + "404": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/AgentPatch" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Not Found", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "422": { "content": { "application/problem+json": { "schema": { @@ -25626,82 +34596,29 @@ } } }, - "description": "Error", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name patches agent by base" - } - }, - "/v0/city/{cityName}/patches/agent/{dir}/{base}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-patches-agent-by-dir-by-base", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Agent directory (rig name).", - "in": "path", - "name": "dir", - "required": true, - "schema": { - "description": "Agent directory (rig name).", - "type": "string" - } }, - { - "description": "Agent base name.", - "in": "path", - "name": "base", - "required": true, - "schema": { - "description": "Agent base name.", - "type": "string" - } - } - ], - "responses": { - "200": { + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/PatchDeletedResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "501": { "content": { "application/problem+json": { "schema": { @@ -25709,7 +34626,7 @@ } } }, - "description": "Error", + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -25717,10 +34634,10 @@ } } }, - "summary": "Delete v0 city by city name patches agent by dir by base" + "summary": "Delete v0 city by city name rig by name" }, "get": { - "operationId": "get-v0-city-by-city-name-patches-agent-by-dir-by-base", + "operationId": "get-v0-city-by-city-name-rig-by-name", "parameters": [ { "description": "City name.", @@ -25735,23 +34652,23 @@ } }, { - "description": "Agent directory (rig name).", + "description": "Rig name.", "in": "path", - "name": "dir", + "name": "name", "required": true, "schema": { - "description": "Agent directory (rig name).", + "description": "Rig name.", "type": "string" } }, { - "description": "Agent base name.", - "in": "path", - "name": "base", - "required": true, + "description": "Include git status.", + "explode": false, + "in": "query", + "name": "git", "schema": { - "description": "Agent base name.", - "type": "string" + "description": "Include git status.", + "type": "boolean" } } ], @@ -25760,7 +34677,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AgentPatch" + "$ref": "#/components/schemas/RigResponse" } } }, @@ -25786,7 +34703,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -25794,66 +34711,14 @@ } } }, - "description": "Error", - "headers": { - "X-GC-Request-Id": { - "$ref": "#/components/headers/X-GC-Request-Id" - } - } - } - }, - "summary": "Get v0 city by city name patches agent by dir by base" - } - }, - "/v0/city/{cityName}/patches/agents": { - "get": { - "operationId": "get-v0-city-by-city-name-patches-agents", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListBodyAgentPatch" - } - } - }, - "description": "OK", + "description": "Not Found", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "422": { "content": { "application/problem+json": { "schema": { @@ -25861,70 +34726,14 @@ } } }, - "description": "Error", - "headers": { - "X-GC-Request-Id": { - "$ref": "#/components/headers/X-GC-Request-Id" - } - } - } - }, - "summary": "Get v0 city by city name patches agents" - }, - "put": { - "operationId": "put-v0-city-by-city-name-patches-agents", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentPatchSetInputBody" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchOKResponseBody" - } - } - }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -25932,7 +34741,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -25940,12 +34749,10 @@ } } }, - "summary": "Put v0 city by city name patches agents" - } - }, - "/v0/city/{cityName}/patches/provider/{name}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-patches-provider-by-name", + "summary": "Get v0 city by city name rig by name" + }, + "patch": { + "operationId": "patch-v0-city-by-city-name-rig-by-name", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -25971,22 +34778,32 @@ } }, { - "description": "Provider patch name.", + "description": "Rig name.", "in": "path", "name": "name", "required": true, "schema": { - "description": "Provider patch name.", + "description": "Rig name.", "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RigUpdateInputBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchDeletedResponseBody" + "$ref": "#/components/schemas/OKResponseBody" } } }, @@ -25997,7 +34814,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -26005,74 +34822,14 @@ } } }, - "description": "Error", - "headers": { - "X-GC-Request-Id": { - "$ref": "#/components/headers/X-GC-Request-Id" - } - } - } - }, - "summary": "Delete v0 city by city name patches provider by name" - }, - "get": { - "operationId": "get-v0-city-by-city-name-patches-provider-by-name", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Provider patch name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Provider patch name.", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProviderPatch" - } - } - }, - "description": "OK", + "description": "Bad Request", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -26080,66 +34837,29 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name patches provider by name" - } - }, - "/v0/city/{cityName}/patches/providers": { - "get": { - "operationId": "get-v0-city-by-city-name-patches-providers", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { + }, + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ListBodyProviderPatch" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Forbidden", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -26147,70 +34867,44 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name patches providers" - }, - "put": { - "operationId": "put-v0-city-by-city-name-patches-providers", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProviderPatchSetInputBody" + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "200": { + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/PatchOKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "501": { "content": { "application/problem+json": { "schema": { @@ -26218,7 +34912,7 @@ } } }, - "description": "Error", + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26226,12 +34920,12 @@ } } }, - "summary": "Put v0 city by city name patches providers" + "summary": "Patch v0 city by city name rig by name" } }, - "/v0/city/{cityName}/patches/rig/{name}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-patches-rig-by-name", + "/v0/city/{cityName}/rig/{name}/{action}": { + "post": { + "operationId": "post-v0-city-by-city-name-rig-by-name-by-action", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -26257,12 +34951,27 @@ } }, { - "description": "Rig patch name.", + "description": "Rig name.", "in": "path", "name": "name", "required": true, "schema": { - "description": "Rig patch name.", + "description": "Rig name.", + "type": "string" + } + }, + { + "description": "Action to perform.", + "in": "path", + "name": "action", + "required": true, + "schema": { + "description": "Action to perform.", + "enum": [ + "suspend", + "resume", + "restart" + ], "type": "string" } } @@ -26272,7 +34981,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchDeletedResponseBody" + "$ref": "#/components/schemas/RigActionBody" } } }, @@ -26283,7 +34992,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -26291,7 +35000,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26299,10 +35083,12 @@ } } }, - "summary": "Delete v0 city by city name patches rig by name" - }, + "summary": "Post v0 city by city name rig by name by action" + } + }, + "/v0/city/{cityName}/rigs": { "get": { - "operationId": "get-v0-city-by-city-name-patches-rig-by-name", + "operationId": "get-v0-city-by-city-name-rigs", "parameters": [ { "description": "City name.", @@ -26317,14 +35103,34 @@ } }, { - "description": "Rig patch name.", - "in": "path", - "name": "name", - "required": true, + "description": "Event sequence number; when provided, blocks until a newer event arrives.", + "explode": false, + "in": "query", + "name": "index", "schema": { - "description": "Rig patch name.", + "description": "Event sequence number; when provided, blocks until a newer event arrives.", + "type": "string" + } + }, + { + "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", + "explode": false, + "in": "query", + "name": "wait", + "schema": { + "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", "type": "string" } + }, + { + "description": "Include git status.", + "explode": false, + "in": "query", + "name": "git", + "schema": { + "description": "Include git status.", + "type": "boolean" + } } ], "responses": { @@ -26332,7 +35138,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RigPatch" + "$ref": "#/components/schemas/ListBodyRigResponse" } } }, @@ -26358,7 +35164,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -26366,66 +35172,44 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name patches rig by name" - } - }, - "/v0/city/{cityName}/patches/rigs": { - "get": { - "operationId": "get-v0-city-by-city-name-patches-rigs", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { + }, + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ListBodyRigPatch" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Internal Server Error", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -26433,7 +35217,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26441,10 +35225,10 @@ } } }, - "summary": "Get v0 city by city name patches rigs" + "summary": "Get v0 city by city name rigs" }, - "put": { - "operationId": "put-v0-city-by-city-name-patches-rigs", + "post": { + "operationId": "create-rig", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -26474,29 +35258,29 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RigPatchSetInputBody" + "$ref": "#/components/schemas/RigCreateInputBody" } } }, "required": true }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchOKResponseBody" + "$ref": "#/components/schemas/RigCreatedOutputBody" } } }, - "description": "OK", + "description": "Created", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -26504,7 +35288,112 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26512,12 +35401,12 @@ } } }, - "summary": "Put v0 city by city name patches rigs" + "summary": "Create a rig" } }, - "/v0/city/{cityName}/pending": { + "/v0/city/{cityName}/service/{name}": { "get": { - "operationId": "get-v0-city-by-city-name-pending", + "operationId": "get-v0-city-by-city-name-service-by-name", "parameters": [ { "description": "City name.", @@ -26530,6 +35419,16 @@ "pattern": "\\S", "type": "string" } + }, + { + "description": "Service name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Service name.", + "type": "string" + } } ], "responses": { @@ -26537,7 +35436,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListBodyCityPendingEntry" + "$ref": "#/components/schemas/Status" } } }, @@ -26563,7 +35462,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -26571,71 +35470,29 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name pending" - } - }, - "/v0/city/{cityName}/provider-readiness": { - "get": { - "operationId": "get-v0-city-by-city-name-provider-readiness", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Comma-separated provider names to check (default: claude,codex,gemini).", - "explode": false, - "in": "query", - "name": "providers", - "schema": { - "description": "Comma-separated provider names to check (default: claude,codex,gemini).", - "type": "string" - } }, - { - "description": "Force fresh probe, bypassing cache.", - "explode": false, - "in": "query", - "name": "fresh", - "schema": { - "description": "Force fresh probe, bypassing cache.", - "type": "boolean" - } - } - ], - "responses": { - "200": { + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProviderReadinessResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -26643,7 +35500,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26651,12 +35508,12 @@ } } }, - "summary": "Get v0 city by city name provider readiness" + "summary": "Get v0 city by city name service by name" } }, - "/v0/city/{cityName}/provider/{name}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-provider-by-name", + "/v0/city/{cityName}/service/{name}/restart": { + "post": { + "operationId": "post-v0-city-by-city-name-service-by-name-restart", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -26682,12 +35539,12 @@ } }, { - "description": "Provider name.", + "description": "Service name.", "in": "path", "name": "name", "required": true, "schema": { - "description": "Provider name.", + "description": "Service name.", "type": "string" } } @@ -26697,18 +35554,78 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ServiceRestartOutputBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -26716,7 +35633,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26724,10 +35641,12 @@ } } }, - "summary": "Delete v0 city by city name provider by name" - }, + "summary": "Post v0 city by city name service by name restart" + } + }, + "/v0/city/{cityName}/services": { "get": { - "operationId": "get-v0-city-by-city-name-provider-by-name", + "operationId": "get-v0-city-by-city-name-services", "parameters": [ { "description": "City name.", @@ -26740,16 +35659,6 @@ "pattern": "\\S", "type": "string" } - }, - { - "description": "Provider name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Provider name.", - "type": "string" - } } ], "responses": { @@ -26757,7 +35666,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProviderResponse" + "$ref": "#/components/schemas/ListBodyStatus" } } }, @@ -26783,7 +35692,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -26791,80 +35700,29 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name provider by name" - }, - "patch": { - "operationId": "patch-v0-city-by-city-name-provider-by-name", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Provider name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Provider name.", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProviderUpdateInputBody" - } - } }, - "required": true - }, - "responses": { - "200": { + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -26872,7 +35730,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26880,12 +35738,12 @@ } } }, - "summary": "Patch v0 city by city name provider by name" + "summary": "Get v0 city by city name services" } }, - "/v0/city/{cityName}/providers": { + "/v0/city/{cityName}/session/{id}": { "get": { - "operationId": "get-v0-city-by-city-name-providers", + "operationId": "get-v0-city-by-city-name-session-by-id", "parameters": [ { "description": "City name.", @@ -26898,6 +35756,39 @@ "pattern": "\\S", "type": "string" } + }, + { + "description": "Session ID, alias, or runtime session_name.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Session ID, alias, or runtime session_name.", + "type": "string" + } + }, + { + "description": "Include last output preview.", + "explode": false, + "in": "query", + "name": "peek", + "schema": { + "description": "Include last output preview.", + "type": "boolean" + } + }, + { + "description": "Number of lines to include in the last output preview when peek=true. Defaults to 5.", + "explode": false, + "in": "query", + "name": "peek_lines", + "schema": { + "description": "Number of lines to include in the last output preview when peek=true. Defaults to 5.", + "format": "int64", + "maximum": 10000, + "minimum": 0, + "type": "integer" + } } ], "responses": { @@ -26905,7 +35796,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListBodyProviderResponse" + "$ref": "#/components/schemas/SessionResponse" } } }, @@ -26931,7 +35822,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -26939,7 +35830,67 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26947,10 +35898,10 @@ } } }, - "summary": "Get v0 city by city name providers" + "summary": "Get v0 city by city name session by ID" }, - "post": { - "operationId": "create-provider", + "patch": { + "operationId": "patch-v0-city-by-city-name-session-by-id", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -26974,35 +35925,60 @@ "pattern": "\\S", "type": "string" } + }, + { + "description": "Session ID, alias, or runtime session_name.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Session ID, alias, or runtime session_name.", + "type": "string" + } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProviderCreateInputBody" + "$ref": "#/components/schemas/SessionPatchBody" } } }, "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProviderCreatedOutputBody" + "$ref": "#/components/schemas/SessionResponse" } } }, - "description": "Created", + "description": "OK", "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -27010,59 +35986,44 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Create a provider" - } - }, - "/v0/city/{cityName}/providers/public": { - "get": { - "operationId": "get-v0-city-by-city-name-providers-public", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProviderPublicListBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Forbidden", "headers": { - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -27070,71 +36031,29 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name providers public" - } - }, - "/v0/city/{cityName}/readiness": { - "get": { - "operationId": "get-v0-city-by-city-name-readiness", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Comma-separated readiness items to check (default: claude,codex,gemini,github_cli).", - "explode": false, - "in": "query", - "name": "items", - "schema": { - "description": "Comma-separated readiness items to check (default: claude,codex,gemini,github_cli).", - "type": "string" - } }, - { - "description": "Force fresh probe, bypassing cache.", - "explode": false, - "in": "query", - "name": "fresh", - "schema": { - "description": "Force fresh probe, bypassing cache.", - "type": "boolean" - } - } - ], - "responses": { - "200": { + "409": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ReadinessResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Conflict", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "422": { "content": { "application/problem+json": { "schema": { @@ -27142,72 +36061,29 @@ } } }, - "description": "Error", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name readiness" - } - }, - "/v0/city/{cityName}/rig/{name}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-rig-by-name", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Rig name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Rig name.", - "type": "string" - } - } - ], - "responses": { - "200": { + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -27215,7 +36091,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -27223,10 +36099,12 @@ } } }, - "summary": "Delete v0 city by city name rig by name" - }, + "summary": "Patch v0 city by city name session by ID" + } + }, + "/v0/city/{cityName}/session/{id}/agents": { "get": { - "operationId": "get-v0-city-by-city-name-rig-by-name", + "operationId": "get-v0-city-by-city-name-session-by-id-agents", "parameters": [ { "description": "City name.", @@ -27241,24 +36119,14 @@ } }, { - "description": "Rig name.", + "description": "Session ID, alias, or runtime session_name.", "in": "path", - "name": "name", + "name": "id", "required": true, "schema": { - "description": "Rig name.", + "description": "Session ID, alias, or runtime session_name.", "type": "string" } - }, - { - "description": "Include git status.", - "explode": false, - "in": "query", - "name": "git", - "schema": { - "description": "Include git status.", - "type": "boolean" - } } ], "responses": { @@ -27266,7 +36134,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RigResponse" + "$ref": "#/components/schemas/SessionAgentListResponse" } } }, @@ -27292,7 +36160,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -27300,80 +36168,29 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name rig by name" - }, - "patch": { - "operationId": "patch-v0-city-by-city-name-rig-by-name", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Rig name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Rig name.", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RigUpdateInputBody" - } - } }, - "required": true - }, - "responses": { - "200": { + "409": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Conflict", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "422": { "content": { "application/problem+json": { "schema": { @@ -27381,82 +36198,29 @@ } } }, - "description": "Error", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Patch v0 city by city name rig by name" - } - }, - "/v0/city/{cityName}/rig/{name}/{action}": { - "post": { - "operationId": "post-v0-city-by-city-name-rig-by-name-by-action", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Rig name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Rig name.", - "type": "string" - } - }, - { - "description": "Action to perform (suspend, resume, restart).", - "in": "path", - "name": "action", - "required": true, - "schema": { - "description": "Action to perform (suspend, resume, restart).", - "type": "string" - } - } - ], - "responses": { - "200": { + }, + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/RigActionBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -27464,7 +36228,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -27472,12 +36236,12 @@ } } }, - "summary": "Post v0 city by city name rig by name by action" + "summary": "Get v0 city by city name session by ID agents" } }, - "/v0/city/{cityName}/rigs": { + "/v0/city/{cityName}/session/{id}/agents/{agentId}": { "get": { - "operationId": "get-v0-city-by-city-name-rigs", + "operationId": "get-v0-city-by-city-name-session-by-id-agents-by-agent-id", "parameters": [ { "description": "City name.", @@ -27492,34 +36256,24 @@ } }, { - "description": "Event sequence number; when provided, blocks until a newer event arrives.", - "explode": false, - "in": "query", - "name": "index", + "description": "Session ID, alias, or runtime session_name.", + "in": "path", + "name": "id", + "required": true, "schema": { - "description": "Event sequence number; when provided, blocks until a newer event arrives.", + "description": "Session ID, alias, or runtime session_name.", "type": "string" } }, { - "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", - "explode": false, - "in": "query", - "name": "wait", + "description": "Subagent ID within the session.", + "in": "path", + "name": "agentId", + "required": true, "schema": { - "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", + "description": "Subagent ID within the session.", "type": "string" } - }, - { - "description": "Include git status.", - "explode": false, - "in": "query", - "name": "git", - "schema": { - "description": "Include git status.", - "type": "boolean" - } } ], "responses": { @@ -27527,7 +36281,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListBodyRigResponse" + "$ref": "#/components/schemas/SessionAgentGetResponse" } } }, @@ -27553,7 +36307,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -27561,70 +36315,74 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name rigs" - }, - "post": { - "operationId": "create-rig", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RigCreateInputBody" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "201": { + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/RigCreatedOutputBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "Created", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -27632,7 +36390,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -27640,13 +36398,24 @@ } } }, - "summary": "Create a rig" + "summary": "Get v0 city by city name session by ID agents by agent ID" } }, - "/v0/city/{cityName}/service/{name}": { - "get": { - "operationId": "get-v0-city-by-city-name-service-by-name", + "/v0/city/{cityName}/session/{id}/close": { + "post": { + "operationId": "post-v0-city-by-city-name-session-by-id-close", "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, { "description": "City name.", "in": "path", @@ -27660,14 +36429,24 @@ } }, { - "description": "Service name.", + "description": "Session ID, alias, or runtime session_name.", "in": "path", - "name": "name", + "name": "id", "required": true, "schema": { - "description": "Service name.", + "description": "Session ID, alias, or runtime session_name.", "type": "string" } + }, + { + "description": "Permanently delete bead after closing.", + "explode": false, + "in": "query", + "name": "delete", + "schema": { + "description": "Permanently delete bead after closing.", + "type": "boolean" + } } ], "responses": { @@ -27675,33 +36454,48 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Status" + "$ref": "#/components/schemas/OKResponseBody" } } }, "description": "OK", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Forbidden", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -27709,7 +36503,67 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -27717,12 +36571,12 @@ } } }, - "summary": "Get v0 city by city name service by name" + "summary": "Post v0 city by city name session by ID close" } }, - "/v0/city/{cityName}/service/{name}/restart": { + "/v0/city/{cityName}/session/{id}/kill": { "post": { - "operationId": "post-v0-city-by-city-name-service-by-name-restart", + "operationId": "post-v0-city-by-city-name-session-by-id-kill", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -27748,12 +36602,12 @@ } }, { - "description": "Service name.", + "description": "Session ID, alias, or runtime session_name.", "in": "path", - "name": "name", + "name": "id", "required": true, "schema": { - "description": "Service name.", + "description": "Session ID, alias, or runtime session_name.", "type": "string" } } @@ -27763,7 +36617,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ServiceRestartOutputBody" + "$ref": "#/components/schemas/OKWithIDResponseBody" } } }, @@ -27774,7 +36628,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -27782,66 +36636,29 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name service by name restart" - } - }, - "/v0/city/{cityName}/services": { - "get": { - "operationId": "get-v0-city-by-city-name-services", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { + }, + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ListBodyStatus" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Forbidden", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -27849,99 +36666,59 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name services" - } - }, - "/v0/city/{cityName}/session/{id}": { - "get": { - "operationId": "get-v0-city-by-city-name-session-by-id", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Session ID, alias, or runtime session_name.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Session ID, alias, or runtime session_name.", - "type": "string" - } - }, - { - "description": "Include last output preview.", - "explode": false, - "in": "query", - "name": "peek", - "schema": { - "description": "Include last output preview.", - "type": "boolean" - } }, - { - "description": "Number of lines to include in the last output preview when peek=true. Defaults to 5.", - "explode": false, - "in": "query", - "name": "peek_lines", - "schema": { - "description": "Number of lines to include in the last output preview when peek=true. Defaults to 5.", - "format": "int64", - "maximum": 10000, - "minimum": 0, - "type": "integer" - } - } - ], - "responses": { - "200": { + "409": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/SessionResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Conflict", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Internal Server Error", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -27949,7 +36726,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -27957,10 +36734,12 @@ } } }, - "summary": "Get v0 city by city name session by ID" - }, - "patch": { - "operationId": "patch-v0-city-by-city-name-session-by-id", + "summary": "Post v0 city by city name session by ID kill" + } + }, + "/v0/city/{cityName}/session/{id}/messages": { + "post": { + "operationId": "send-session-message", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -28000,44 +36779,59 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionPatchBody" + "$ref": "#/components/schemas/SessionMessageInputBody" } } }, "required": true }, "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionResponse" + "$ref": "#/components/schemas/AsyncAcceptedBody" } } }, - "description": "OK", + "description": "Accepted", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Forbidden", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -28045,76 +36839,44 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Patch v0 city by city name session by ID" - } - }, - "/v0/city/{cityName}/session/{id}/agents": { - "get": { - "operationId": "get-v0-city-by-city-name-session-by-id-agents", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Session ID, alias, or runtime session_name.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Session ID, alias, or runtime session_name.", - "type": "string" + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } - } - ], - "responses": { - "200": { + }, + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/SessionAgentListResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -28122,7 +36884,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -28130,12 +36892,12 @@ } } }, - "summary": "Get v0 city by city name session by ID agents" + "summary": "Send a message to a session" } }, - "/v0/city/{cityName}/session/{id}/agents/{agentId}": { + "/v0/city/{cityName}/session/{id}/pending": { "get": { - "operationId": "get-v0-city-by-city-name-session-by-id-agents-by-agent-id", + "operationId": "get-v0-city-by-city-name-session-by-id-pending", "parameters": [ { "description": "City name.", @@ -28158,16 +36920,6 @@ "description": "Session ID, alias, or runtime session_name.", "type": "string" } - }, - { - "description": "Subagent ID within the session.", - "in": "path", - "name": "agentId", - "required": true, - "schema": { - "description": "Subagent ID within the session.", - "type": "string" - } } ], "responses": { @@ -28175,7 +36927,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionAgentGetResponse" + "$ref": "#/components/schemas/SessionPendingResponse" } } }, @@ -28201,7 +36953,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -28209,82 +36961,29 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name session by ID agents by agent ID" - } - }, - "/v0/city/{cityName}/session/{id}/close": { - "post": { - "operationId": "post-v0-city-by-city-name-session-by-id-close", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Session ID, alias, or runtime session_name.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Session ID, alias, or runtime session_name.", - "type": "string" - } }, - { - "description": "Permanently delete bead after closing.", - "explode": false, - "in": "query", - "name": "delete", - "schema": { - "description": "Permanently delete bead after closing.", - "type": "boolean" - } - } - ], - "responses": { - "200": { + "409": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Conflict", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "422": { "content": { "application/problem+json": { "schema": { @@ -28292,72 +36991,29 @@ } } }, - "description": "Error", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name session by ID close" - } - }, - "/v0/city/{cityName}/session/{id}/kill": { - "post": { - "operationId": "post-v0-city-by-city-name-session-by-id-kill", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Session ID, alias, or runtime session_name.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Session ID, alias, or runtime session_name.", - "type": "string" - } - } - ], - "responses": { - "200": { + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKWithIDResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -28365,7 +37021,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -28373,12 +37029,12 @@ } } }, - "summary": "Post v0 city by city name session by ID kill" + "summary": "Get v0 city by city name session by ID pending" } }, - "/v0/city/{cityName}/session/{id}/messages": { + "/v0/city/{cityName}/session/{id}/permission-mode": { "post": { - "operationId": "send-session-message", + "operationId": "post-v0-city-by-city-name-session-by-id-permission-mode", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -28418,29 +37074,44 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionMessageInputBody" + "$ref": "#/components/schemas/SessionPermissionModeBody" } } }, "required": true }, "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AsyncAcceptedBody" + "$ref": "#/components/schemas/SessionResponse" } } }, - "description": "Accepted", + "description": "OK", "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -28448,76 +37119,119 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Send a message to a session" - } - }, - "/v0/city/{cityName}/session/{id}/pending": { - "get": { - "operationId": "get-v0-city-by-city-name-session-by-id-pending", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "Session ID, alias, or runtime session_name.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Session ID, alias, or runtime session_name.", - "type": "string" + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } - } - ], - "responses": { - "200": { + }, + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/SessionPendingResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Not Implemented", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -28525,7 +37239,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -28533,12 +37247,12 @@ } } }, - "summary": "Get v0 city by city name session by ID pending" + "summary": "Post v0 city by city name session by ID permission mode" } }, - "/v0/city/{cityName}/session/{id}/permission-mode": { + "/v0/city/{cityName}/session/{id}/rename": { "post": { - "operationId": "post-v0-city-by-city-name-session-by-id-permission-mode", + "operationId": "post-v0-city-by-city-name-session-by-id-rename", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -28578,7 +37292,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionPermissionModeBody" + "$ref": "#/components/schemas/SessionRenameInputBody" } } }, @@ -28615,7 +37329,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -28623,7 +37337,112 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -28631,12 +37450,12 @@ } } }, - "summary": "Post v0 city by city name session by ID permission mode" + "summary": "Post v0 city by city name session by ID rename" } }, - "/v0/city/{cityName}/session/{id}/rename": { + "/v0/city/{cityName}/session/{id}/respond": { "post": { - "operationId": "post-v0-city-by-city-name-session-by-id-rename", + "operationId": "respond-session", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -28676,44 +37495,59 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionRenameInputBody" + "$ref": "#/components/schemas/SessionRespondInputBody" } } }, "required": true }, "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionResponse" + "$ref": "#/components/schemas/SessionRespondOutputBody" } } }, - "description": "OK", + "description": "Accepted", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Forbidden", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -28721,82 +37555,74 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name session by ID rename" - } - }, - "/v0/city/{cityName}/session/{id}/respond": { - "post": { - "operationId": "respond-session", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "Session ID, alias, or runtime session_name.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Session ID, alias, or runtime session_name.", - "type": "string" + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SessionRespondInputBody" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "202": { + "501": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/SessionRespondOutputBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "Accepted", + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -28804,7 +37630,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -28869,7 +37695,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -28877,7 +37703,97 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29174,7 +38090,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -29182,7 +38098,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29247,7 +38238,97 @@ } } }, - "default": { + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { "content": { "application/problem+json": { "schema": { @@ -29255,7 +38336,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29364,7 +38445,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -29372,7 +38453,67 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29437,7 +38578,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -29445,7 +38586,112 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29556,7 +38802,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -29564,7 +38810,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29609,25 +38900,100 @@ } } }, - "required": true - }, - "responses": { - "202": { + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncAcceptedBody" + } + } + }, + "description": "Accepted", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/AsyncAcceptedBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "Accepted", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -29635,7 +39001,22 @@ } } }, - "description": "Error", + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29700,7 +39081,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -29708,7 +39089,97 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29797,7 +39268,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -29805,7 +39276,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29961,7 +39477,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -29969,7 +39485,82 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -30056,7 +39647,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -30064,7 +39655,52 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" diff --git a/docs/reference/schema/openapi.txt b/docs/reference/schema/openapi.txt index 1f15a4bb4b..ddd26ef171 100644 --- a/docs/reference/schema/openapi.txt +++ b/docs/reference/schema/openapi.txt @@ -2112,6 +2112,10 @@ "ErrorModel": { "additionalProperties": false, "properties": { + "code": { + "description": "Stable machine-readable error code (the final segment of the type URN).", + "type": "string" + }, "detail": { "description": "A human-readable explanation specific to this occurrence of the problem.", "examples": [ @@ -2157,16 +2161,88 @@ "description": "A URI reference to human-readable documentation for the error.", "examples": [ "https://example.com/errors/example", - "urn:gascity:error:sling-missing-bead", + "urn:gascity:error:agent-not-found", + "urn:gascity:error:ambiguous-reference", + "urn:gascity:error:bad-gateway", + "urn:gascity:error:bead-not-found", + "urn:gascity:error:city-not-found", + "urn:gascity:error:conflict-concurrent-delete", + "urn:gascity:error:conflict-concurrent-modify", + "urn:gascity:error:conflict-wrong-state", + "urn:gascity:error:convoy-not-found", + "urn:gascity:error:extmsg-group-not-found", + "urn:gascity:error:forbidden", + "urn:gascity:error:formula-not-found", + "urn:gascity:error:gateway-timeout", + "urn:gascity:error:idempotency-in-flight", + "urn:gascity:error:idempotency-mismatch", + "urn:gascity:error:internal", + "urn:gascity:error:invalid-request", + "urn:gascity:error:mail-not-found", + "urn:gascity:error:method-not-allowed", + "urn:gascity:error:not-implemented", + "urn:gascity:error:operation-in-progress", + "urn:gascity:error:order-not-found", + "urn:gascity:error:pack-not-found", + "urn:gascity:error:patch-not-found", + "urn:gascity:error:provider-not-found", + "urn:gascity:error:rig-not-found", + "urn:gascity:error:scope-not-found", + "urn:gascity:error:service-not-found", + "urn:gascity:error:service-unavailable", + "urn:gascity:error:session-conflict", + "urn:gascity:error:session-not-found", "urn:gascity:error:sling-cross-rig", - "urn:gascity:error:sling-cross-store-route" + "urn:gascity:error:sling-cross-store-route", + "urn:gascity:error:sling-missing-bead", + "urn:gascity:error:sling-source-workflow-conflict", + "urn:gascity:error:store-unavailable", + "urn:gascity:error:validation-failed", + "urn:gascity:error:webhook-rejected", + "urn:gascity:error:workflow-not-found" ], "format": "uri", "type": "string", "x-gascity-problem-types": [ - "urn:gascity:error:sling-missing-bead", + "urn:gascity:error:agent-not-found", + "urn:gascity:error:ambiguous-reference", + "urn:gascity:error:bad-gateway", + "urn:gascity:error:bead-not-found", + "urn:gascity:error:city-not-found", + "urn:gascity:error:conflict-concurrent-delete", + "urn:gascity:error:conflict-concurrent-modify", + "urn:gascity:error:conflict-wrong-state", + "urn:gascity:error:convoy-not-found", + "urn:gascity:error:extmsg-group-not-found", + "urn:gascity:error:forbidden", + "urn:gascity:error:formula-not-found", + "urn:gascity:error:gateway-timeout", + "urn:gascity:error:idempotency-in-flight", + "urn:gascity:error:idempotency-mismatch", + "urn:gascity:error:internal", + "urn:gascity:error:invalid-request", + "urn:gascity:error:mail-not-found", + "urn:gascity:error:method-not-allowed", + "urn:gascity:error:not-implemented", + "urn:gascity:error:operation-in-progress", + "urn:gascity:error:order-not-found", + "urn:gascity:error:pack-not-found", + "urn:gascity:error:patch-not-found", + "urn:gascity:error:provider-not-found", + "urn:gascity:error:rig-not-found", + "urn:gascity:error:scope-not-found", + "urn:gascity:error:service-not-found", + "urn:gascity:error:service-unavailable", + "urn:gascity:error:session-conflict", + "urn:gascity:error:session-not-found", "urn:gascity:error:sling-cross-rig", - "urn:gascity:error:sling-cross-store-route" + "urn:gascity:error:sling-cross-store-route", + "urn:gascity:error:sling-missing-bead", + "urn:gascity:error:sling-source-workflow-conflict", + "urn:gascity:error:store-unavailable", + "urn:gascity:error:validation-failed", + "urn:gascity:error:webhook-rejected", + "urn:gascity:error:workflow-not-found" ] } }, @@ -17774,7 +17850,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -17782,7 +17858,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -17845,7 +17951,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -17853,7 +17959,97 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -17918,7 +18114,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -17926,7 +18122,112 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -17993,7 +18294,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -18001,7 +18302,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -18074,7 +18405,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -18082,31 +18413,136 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Patch v0 city by city name agent by base" - } - }, - "/v0/city/{cityName}/agent/{base}/output": { - "get": { - "operationId": "get-v0-city-by-city-name-agent-by-base-output", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Patch v0 city by city name agent by base" + } + }, + "/v0/city/{cityName}/agent/{base}/output": { + "get": { + "operationId": "get-v0-city-by-city-name-agent-by-base-output", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" } }, { @@ -18156,7 +18592,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -18164,7 +18600,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -18371,7 +18837,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -18379,7 +18845,97 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -18454,7 +19010,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -18462,38 +19018,143 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Delete v0 city by city name agent by dir by base" - }, - "get": { - "operationId": "get-v0-city-by-city-name-agent-by-dir-by-base", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "Agent directory (rig name).", - "in": "path", - "name": "dir", - "required": true, - "schema": { - "description": "Agent directory (rig name).", + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name agent by dir by base" + }, + "get": { + "operationId": "get-v0-city-by-city-name-agent-by-dir-by-base", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Agent directory (rig name).", + "in": "path", + "name": "dir", + "required": true, + "schema": { + "description": "Agent directory (rig name).", "type": "string" } }, @@ -18539,7 +19200,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -18547,7 +19208,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -18630,7 +19321,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -18638,7 +19329,112 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -18722,7 +19518,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -18730,7 +19526,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -18957,7 +19783,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -18965,14 +19791,104 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, "summary": "Post v0 city by city name agent by dir by base by action" } }, @@ -19088,7 +20004,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -19096,7 +20012,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -19160,7 +20106,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -19168,7 +20114,142 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "504": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Gateway Timeout", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -19233,7 +20314,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -19241,40 +20322,115 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Delete v0 city by city name bead by ID" - }, - "get": { - "operationId": "get-v0-city-by-city-name-bead-by-id", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Bead ID.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Bead ID.", - "type": "string" - } + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name bead by ID" + }, + "get": { + "operationId": "get-v0-city-by-city-name-bead-by-id", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Bead ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Bead ID.", + "type": "string" + } } ], "responses": { @@ -19308,7 +20464,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -19316,7 +20472,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -19389,7 +20590,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -19397,7 +20598,97 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -19490,7 +20781,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -19498,45 +20789,135 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name bead by ID assign" - } - }, - "/v0/city/{cityName}/bead/{id}/close": { - "post": { - "operationId": "post-v0-city-by-city-name-bead-by-id-close", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name bead by ID assign" + } + }, + "/v0/city/{cityName}/bead/{id}/close": { + "post": { + "operationId": "post-v0-city-by-city-name-bead-by-id-close", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { "description": "Bead ID.", "in": "path", "name": "id", @@ -19563,7 +20944,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -19571,7 +20952,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -19640,7 +21096,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -19648,7 +21104,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -19713,7 +21199,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -19721,82 +21207,29 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name bead by ID reopen" - } - }, - "/v0/city/{cityName}/bead/{id}/update": { - "post": { - "operationId": "post-v0-city-by-city-name-bead-by-id-update", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Bead ID.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Bead ID.", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BeadUpdateBody" - } - } }, - "required": true - }, - "responses": { - "200": { + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Forbidden", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -19804,13 +21237,231 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name bead by ID reopen" + } + }, + "/v0/city/{cityName}/bead/{id}/update": { + "post": { + "operationId": "post-v0-city-by-city-name-bead-by-id-update", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Bead ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Bead ID.", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BeadUpdateBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } }, "summary": "Post v0 city by city name bead by ID update" } @@ -19965,7 +21616,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -19973,7 +21624,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20060,7 +21756,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -20068,26 +21764,116 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Create a bead" - } - }, - "/v0/city/{cityName}/beads/graph/{rootID}": { - "get": { - "operationId": "get-v0-city-by-city-name-beads-graph-by-root-id", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Create a bead" + } + }, + "/v0/city/{cityName}/beads/graph/{rootID}": { + "get": { + "operationId": "get-v0-city-by-city-name-beads-graph-by-root-id", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, "schema": { "description": "City name.", "minLength": 1, @@ -20137,7 +21923,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -20145,7 +21931,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20224,7 +22040,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -20232,7 +22048,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20291,7 +22152,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -20299,7 +22160,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20358,7 +22249,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -20366,7 +22257,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20425,7 +22346,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -20433,7 +22354,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20477,7 +22428,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -20485,21 +22436,51 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name config validate" - } - }, - "/v0/city/{cityName}/convoy/{id}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-convoy-by-id", - "parameters": [ + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name config validate" + } + }, + "/v0/city/{cityName}/convoy/{id}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-convoy-by-id", + "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", "in": "header", @@ -20550,7 +22531,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -20558,7 +22539,82 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20625,7 +22681,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -20633,7 +22689,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20708,7 +22809,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -20716,7 +22817,82 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20785,7 +22961,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -20793,48 +22969,108 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name convoy by ID check" - } - }, - "/v0/city/{cityName}/convoy/{id}/close": { - "post": { - "operationId": "post-v0-city-by-city-name-convoy-by-id-close", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "Convoy ID.", - "in": "path", - "name": "id", + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name convoy by ID check" + } + }, + "/v0/city/{cityName}/convoy/{id}/close": { + "post": { + "operationId": "post-v0-city-by-city-name-convoy-by-id-close", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Convoy ID.", + "in": "path", + "name": "id", "required": true, "schema": { "description": "Convoy ID.", @@ -20858,7 +23094,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -20866,7 +23102,82 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20941,7 +23252,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -20949,7 +23260,82 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -21050,7 +23436,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -21058,7 +23444,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -21136,7 +23567,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -21144,17 +23575,92 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Create a convoy" - } - }, + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Create a convoy" + } + }, "/v0/city/{cityName}/events": { "get": { "operationId": "get-v0-city-by-city-name-events", @@ -21275,7 +23781,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -21283,7 +23789,52 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -21346,7 +23897,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -21354,7 +23905,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -21419,7 +24045,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -21427,7 +24053,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Method Not Allowed", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -21622,7 +24323,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -21630,21 +24331,96 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Delete v0 city by city name extmsg adapters" - }, - "get": { - "operationId": "get-v0-city-by-city-name-extmsg-adapters", - "parameters": [ - { - "description": "City name.", + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name extmsg adapters" + }, + "get": { + "operationId": "get-v0-city-by-city-name-extmsg-adapters", + "parameters": [ + { + "description": "City name.", "in": "path", "name": "cityName", "required": true, @@ -21687,7 +24463,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -21695,7 +24471,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -21758,7 +24579,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -21766,7 +24587,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -21831,7 +24727,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -21839,76 +24735,44 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name extmsg bind" - } - }, - "/v0/city/{cityName}/extmsg/bindings": { - "get": { - "operationId": "get-v0-city-by-city-name-extmsg-bindings", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Session ID to list bindings for.", - "explode": false, - "in": "query", - "name": "session_id", - "schema": { - "description": "Session ID to list bindings for.", - "type": "string" - } - } - ], - "responses": { - "200": { + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ListBodySessionBindingRecord" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unauthorized", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Forbidden", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -21916,20 +24780,217 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name extmsg bindings" - } - }, - "/v0/city/{cityName}/extmsg/groups": { - "get": { - "operationId": "get-v0-city-by-city-name-extmsg-groups", + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name extmsg bind" + } + }, + "/v0/city/{cityName}/extmsg/bindings": { + "get": { + "operationId": "get-v0-city-by-city-name-extmsg-bindings", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Session ID to list bindings for.", + "explode": false, + "in": "query", + "name": "session_id", + "schema": { + "description": "Session ID to list bindings for.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListBodySessionBindingRecord" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name extmsg bindings" + } + }, + "/v0/city/{cityName}/extmsg/groups": { + "get": { + "operationId": "get-v0-city-by-city-name-extmsg-groups", "parameters": [ { "description": "City name.", @@ -22010,7 +25071,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -22018,7 +25079,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22081,7 +25187,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -22089,72 +25195,59 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Ensure an external messaging group exists" - } - }, - "/v0/city/{cityName}/extmsg/inbound": { - "post": { - "operationId": "post-v0-city-by-city-name-extmsg-inbound", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExtMsgInboundInputBody" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "200": { + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/InboundResult" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -22162,7 +25255,22 @@ } } }, - "description": "Error", + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22170,12 +25278,12 @@ } } }, - "summary": "Post v0 city by city name extmsg inbound" + "summary": "Ensure an external messaging group exists" } }, - "/v0/city/{cityName}/extmsg/outbound": { + "/v0/city/{cityName}/extmsg/inbound": { "post": { - "operationId": "post-v0-city-by-city-name-extmsg-outbound", + "operationId": "post-v0-city-by-city-name-extmsg-inbound", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -22205,7 +25313,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExtMsgOutboundInputBody" + "$ref": "#/components/schemas/ExtMsgInboundInputBody" } } }, @@ -22216,7 +25324,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OutboundResult" + "$ref": "#/components/schemas/InboundResult" } } }, @@ -22227,7 +25335,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -22235,7 +25343,97 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22243,12 +25441,12 @@ } } }, - "summary": "Post v0 city by city name extmsg outbound" + "summary": "Post v0 city by city name extmsg inbound" } }, - "/v0/city/{cityName}/extmsg/participants": { - "delete": { - "operationId": "delete-v0-city-by-city-name-extmsg-participants", + "/v0/city/{cityName}/extmsg/outbound": { + "post": { + "operationId": "post-v0-city-by-city-name-extmsg-outbound", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -22278,7 +25476,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExtMsgParticipantRemoveInputBody" + "$ref": "#/components/schemas/ExtMsgOutboundInputBody" } } }, @@ -22289,7 +25487,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/OutboundResult" } } }, @@ -22300,7 +25498,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -22308,7 +25506,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22316,10 +25589,12 @@ } } }, - "summary": "Delete v0 city by city name extmsg participants" - }, - "post": { - "operationId": "post-v0-city-by-city-name-extmsg-participants", + "summary": "Post v0 city by city name extmsg outbound" + } + }, + "/v0/city/{cityName}/extmsg/participants": { + "delete": { + "operationId": "delete-v0-city-by-city-name-extmsg-participants", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -22349,7 +25624,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExtMsgParticipantUpsertInputBody" + "$ref": "#/components/schemas/ExtMsgParticipantRemoveInputBody" } } }, @@ -22360,7 +25635,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConversationGroupParticipant" + "$ref": "#/components/schemas/OKResponseBody" } } }, @@ -22371,7 +25646,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -22379,27 +25654,248 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name extmsg participants" - } - }, - "/v0/city/{cityName}/extmsg/transcript": { - "get": { - "operationId": "get-v0-city-by-city-name-extmsg-transcript", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name extmsg participants" + }, + "post": { + "operationId": "post-v0-city-by-city-name-extmsg-participants", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExtMsgParticipantUpsertInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationGroupParticipant" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name extmsg participants" + } + }, + "/v0/city/{cityName}/extmsg/transcript": { + "get": { + "operationId": "get-v0-city-by-city-name-extmsg-transcript", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { "description": "City name.", "minLength": 1, "pattern": "\\S", @@ -22534,7 +26030,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -22542,7 +26038,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22607,7 +26148,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -22615,7 +26156,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22680,7 +26296,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -22688,7 +26304,97 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22773,7 +26479,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -22781,7 +26487,67 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22845,7 +26611,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -22853,7 +26619,67 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22929,7 +26755,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -22937,7 +26763,67 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -23004,7 +26890,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -23012,7 +26898,97 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -23095,7 +27071,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -23103,7 +27079,67 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -23180,7 +27216,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -23188,82 +27224,44 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Put v0 city by city name formulas by name" - } - }, - "/v0/city/{cityName}/formulas/{name}/preview": { - "post": { - "operationId": "post-v0-city-by-city-name-formulas-by-name-preview", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Formula name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Formula name.", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FormulaPreviewBody" + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "200": { + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/FormulaDetailResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Forbidden", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -23271,95 +27269,29 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name formulas by name preview" - } - }, - "/v0/city/{cityName}/formulas/{name}/runs": { - "get": { - "operationId": "get-v0-city-by-city-name-formulas-by-name-runs", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Formula name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Formula name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Scope kind (city or rig).", - "explode": false, - "in": "query", - "name": "scope_kind", - "schema": { - "description": "Scope kind (city or rig).", - "type": "string" - } - }, - { - "description": "Scope reference.", - "explode": false, - "in": "query", - "name": "scope_ref", - "schema": { - "description": "Scope reference.", - "type": "string" - } - }, - { - "description": "Maximum number of recent runs to return. 0 = default.", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "description": "Maximum number of recent runs to return. 0 = default.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - } - ], - "responses": { - "200": { + "413": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/FormulaRunsResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Request Entity Too Large", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "422": { "content": { "application/problem+json": { "schema": { @@ -23367,63 +27299,29 @@ } } }, - "description": "Error", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name formulas by name runs" - } - }, - "/v0/city/{cityName}/formulas/{name}/source": { - "get": { - "operationId": "get-v0-city-by-city-name-formulas-by-name-source", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Formula name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Formula name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/FormulaSourceOutputBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "501": { "content": { "application/problem+json": { "schema": { @@ -23431,7 +27329,7 @@ } } }, - "description": "Error", + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -23439,12 +27337,12 @@ } } }, - "summary": "Get v0 city by city name formulas by name source" + "summary": "Put v0 city by city name formulas by name" } }, - "/v0/city/{cityName}/formulas/{name}/validate": { + "/v0/city/{cityName}/formulas/{name}/preview": { "post": { - "operationId": "post-v0-city-by-city-name-formulas-by-name-validate", + "operationId": "post-v0-city-by-city-name-formulas-by-name-preview", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -23476,19 +27374,15 @@ "required": true, "schema": { "description": "Formula name.", - "minLength": 1, - "pattern": "\\S", "type": "string" } } ], "requestBody": { "content": { - "application/octet-stream": { + "application/json": { "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" + "$ref": "#/components/schemas/FormulaPreviewBody" } } }, @@ -23499,7 +27393,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FormulaValidateOutputBody" + "$ref": "#/components/schemas/FormulaDetailResponse" } } }, @@ -23510,7 +27404,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -23518,51 +27412,44 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name formulas by name validate" - } - }, - "/v0/city/{cityName}/health": { - "get": { - "operationId": "get-v0-city-by-city-name-health", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } - } - ], - "responses": { - "200": { + }, + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/HealthOutputBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Forbidden", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -23570,7 +27457,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -23578,12 +27510,12 @@ } } }, - "summary": "Get v0 city by city name health" + "summary": "Post v0 city by city name formulas by name preview" } }, - "/v0/city/{cityName}/mail": { + "/v0/city/{cityName}/formulas/{name}/runs": { "get": { - "operationId": "get-v0-city-by-city-name-mail", + "operationId": "get-v0-city-by-city-name-formulas-by-name-runs", "parameters": [ { "description": "City name.", @@ -23598,76 +27530,48 @@ } }, { - "description": "Event sequence number; when provided, blocks until a newer event arrives.", - "explode": false, - "in": "query", - "name": "index", + "description": "Formula name.", + "in": "path", + "name": "name", + "required": true, "schema": { - "description": "Event sequence number; when provided, blocks until a newer event arrives.", + "description": "Formula name.", + "minLength": 1, + "pattern": "\\S", "type": "string" } }, { - "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", + "description": "Scope kind (city or rig).", "explode": false, "in": "query", - "name": "wait", + "name": "scope_kind", "schema": { - "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", + "description": "Scope kind (city or rig).", "type": "string" } }, { - "description": "Pagination cursor from a previous response's next_cursor field.", + "description": "Scope reference.", "explode": false, "in": "query", - "name": "cursor", + "name": "scope_ref", "schema": { - "description": "Pagination cursor from a previous response's next_cursor field.", + "description": "Scope reference.", "type": "string" } }, { - "description": "Maximum number of results to return. 0 = server default.", + "description": "Maximum number of recent runs to return. 0 = default.", "explode": false, "in": "query", "name": "limit", "schema": { - "description": "Maximum number of results to return. 0 = server default.", + "description": "Maximum number of recent runs to return. 0 = default.", "format": "int64", "minimum": 0, "type": "integer" } - }, - { - "description": "Filter by agent name.", - "explode": false, - "in": "query", - "name": "agent", - "schema": { - "description": "Filter by agent name.", - "type": "string" - } - }, - { - "description": "Filter by status (unread, all).", - "explode": false, - "in": "query", - "name": "status", - "schema": { - "description": "Filter by status (unread, all).", - "type": "string" - } - }, - { - "description": "Filter by rig name.", - "explode": false, - "in": "query", - "name": "rig", - "schema": { - "description": "Filter by rig name.", - "type": "string" - } } ], "responses": { @@ -23675,33 +27579,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MailListBody" + "$ref": "#/components/schemas/FormulaRunsResponse" } } }, "description": "OK", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -23709,94 +27598,59 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name mail" - }, - "post": { - "operationId": "send-mail", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Idempotency key for safe retries.", - "in": "header", - "name": "Idempotency-Key", - "schema": { - "description": "Idempotency key for safe retries.", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MailSendInputBody" + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "201": { + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Message" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "Created", + "description": "Unprocessable Entity", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Internal Server Error", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -23804,7 +27658,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -23812,12 +27666,12 @@ } } }, - "summary": "Send a mail message" + "summary": "Get v0 city by city name formulas by name runs" } }, - "/v0/city/{cityName}/mail/count": { + "/v0/city/{cityName}/formulas/{name}/source": { "get": { - "operationId": "get-v0-city-by-city-name-mail-count", + "operationId": "get-v0-city-by-city-name-formulas-by-name-source", "parameters": [ { "description": "City name.", @@ -23832,22 +27686,14 @@ } }, { - "description": "Filter by agent name.", - "explode": false, - "in": "query", - "name": "agent", - "schema": { - "description": "Filter by agent name.", - "type": "string" - } - }, - { - "description": "Filter by rig name.", - "explode": false, - "in": "query", - "name": "rig", + "description": "Formula name.", + "in": "path", + "name": "name", + "required": true, "schema": { - "description": "Filter by rig name.", + "description": "Formula name.", + "minLength": 1, + "pattern": "\\S", "type": "string" } } @@ -23857,25 +27703,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MailCountOutputBody" + "$ref": "#/components/schemas/FormulaSourceOutputBody" } } }, "description": "OK", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -23883,86 +27722,59 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name mail count" - } - }, - "/v0/city/{cityName}/mail/thread/{id}": { - "get": { - "operationId": "get-v0-city-by-city-name-mail-thread-by-id", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Thread ID, or any message ID in the thread.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Thread ID, or any message ID in the thread.", - "type": "string" - } }, - { - "description": "Filter by rig.", - "explode": false, - "in": "query", - "name": "rig", - "schema": { - "description": "Filter by rig.", - "type": "string" - } - } - ], - "responses": { - "200": { + "404": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/MailListBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Not Found", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Internal Server Error", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "501": { "content": { "application/problem+json": { "schema": { @@ -23970,7 +27782,7 @@ } } }, - "description": "Error", + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -23978,12 +27790,12 @@ } } }, - "summary": "Get v0 city by city name mail thread by ID" + "summary": "Get v0 city by city name formulas by name source" } }, - "/v0/city/{cityName}/mail/{id}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-mail-by-id", + "/v0/city/{cityName}/formulas/{name}/validate": { + "post": { + "operationId": "post-v0-city-by-city-name-formulas-by-name-validate", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -24009,32 +27821,36 @@ } }, { - "description": "Message ID.", + "description": "Formula name.", "in": "path", - "name": "id", + "name": "name", "required": true, "schema": { - "description": "Message ID.", - "type": "string" - } - }, - { - "description": "Rig hint.", - "explode": false, - "in": "query", - "name": "rig", - "schema": { - "description": "Rig hint.", + "description": "Formula name.", + "minLength": 1, + "pattern": "\\S", "type": "string" } } ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/FormulaValidateOutputBody" } } }, @@ -24045,7 +27861,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -24053,84 +27869,74 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Delete v0 city by city name mail by ID" - }, - "get": { - "operationId": "get-v0-city-by-city-name-mail-by-id", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Message ID.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Message ID.", - "type": "string" + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "Rig hint for O(1) lookup.", - "explode": false, - "in": "query", - "name": "rig", - "schema": { - "description": "Rig hint for O(1) lookup.", - "type": "string" - } - } - ], - "responses": { - "200": { + "404": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Message" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Not Found", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "413": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Request Entity Too Large", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Unprocessable Entity", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -24138,7 +27944,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24146,24 +27952,13 @@ } } }, - "summary": "Get v0 city by city name mail by ID" + "summary": "Post v0 city by city name formulas by name validate" } }, - "/v0/city/{cityName}/mail/{id}/archive": { - "post": { - "operationId": "post-v0-city-by-city-name-mail-by-id-archive", + "/v0/city/{cityName}/health": { + "get": { + "operationId": "get-v0-city-by-city-name-health", "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, { "description": "City name.", "in": "path", @@ -24175,26 +27970,6 @@ "pattern": "\\S", "type": "string" } - }, - { - "description": "Message ID.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Message ID.", - "type": "string" - } - }, - { - "description": "Rig hint.", - "explode": false, - "in": "query", - "name": "rig", - "schema": { - "description": "Rig hint.", - "type": "string" - } } ], "responses": { @@ -24202,7 +27977,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/HealthOutputBody" } } }, @@ -24213,7 +27988,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -24221,82 +27996,29 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name mail by ID archive" - } - }, - "/v0/city/{cityName}/mail/{id}/mark-unread": { - "post": { - "operationId": "post-v0-city-by-city-name-mail-by-id-mark-unread", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Message ID.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Message ID.", - "type": "string" - } - }, - { - "description": "Rig hint.", - "explode": false, - "in": "query", - "name": "rig", - "schema": { - "description": "Rig hint.", - "type": "string" - } - } - ], - "responses": { - "200": { + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -24304,7 +28026,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24312,24 +28034,13 @@ } } }, - "summary": "Post v0 city by city name mail by ID mark unread" + "summary": "Get v0 city by city name health" } }, - "/v0/city/{cityName}/mail/{id}/read": { - "post": { - "operationId": "post-v0-city-by-city-name-mail-by-id-read", + "/v0/city/{cityName}/mail": { + "get": { + "operationId": "get-v0-city-by-city-name-mail", "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, { "description": "City name.", "in": "path", @@ -24343,43 +28054,5342 @@ } }, { - "description": "Message ID.", - "in": "path", - "name": "id", - "required": true, + "description": "Event sequence number; when provided, blocks until a newer event arrives.", + "explode": false, + "in": "query", + "name": "index", "schema": { - "description": "Message ID.", + "description": "Event sequence number; when provided, blocks until a newer event arrives.", "type": "string" } }, { - "description": "Rig hint.", + "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", + "explode": false, + "in": "query", + "name": "wait", + "schema": { + "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", + "type": "string" + } + }, + { + "description": "Pagination cursor from a previous response's next_cursor field.", + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "description": "Pagination cursor from a previous response's next_cursor field.", + "type": "string" + } + }, + { + "description": "Maximum number of results to return. 0 = server default.", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "description": "Maximum number of results to return. 0 = server default.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Filter by agent name.", + "explode": false, + "in": "query", + "name": "agent", + "schema": { + "description": "Filter by agent name.", + "type": "string" + } + }, + { + "description": "Filter by status (unread, all).", + "explode": false, + "in": "query", + "name": "status", + "schema": { + "description": "Filter by status (unread, all).", + "type": "string" + } + }, + { + "description": "Filter by rig name.", "explode": false, "in": "query", "name": "rig", "schema": { - "description": "Rig hint.", + "description": "Filter by rig name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailListBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name mail" + }, + "post": { + "operationId": "send-mail", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Idempotency key for safe retries.", + "in": "header", + "name": "Idempotency-Key", + "schema": { + "description": "Idempotency key for safe retries.", "type": "string" } - } - ], - "responses": { - "200": { + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailSendInputBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + }, + "description": "Created", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Send a mail message" + } + }, + "/v0/city/{cityName}/mail/count": { + "get": { + "operationId": "get-v0-city-by-city-name-mail-count", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Filter by agent name.", + "explode": false, + "in": "query", + "name": "agent", + "schema": { + "description": "Filter by agent name.", + "type": "string" + } + }, + { + "description": "Filter by rig name.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Filter by rig name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailCountOutputBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name mail count" + } + }, + "/v0/city/{cityName}/mail/thread/{id}": { + "get": { + "operationId": "get-v0-city-by-city-name-mail-thread-by-id", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Thread ID, or any message ID in the thread.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Thread ID, or any message ID in the thread.", + "type": "string" + } + }, + { + "description": "Filter by rig.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Filter by rig.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailListBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name mail thread by ID" + } + }, + "/v0/city/{cityName}/mail/{id}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-mail-by-id", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Message ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Message ID.", + "type": "string" + } + }, + { + "description": "Rig hint.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Rig hint.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name mail by ID" + }, + "get": { + "operationId": "get-v0-city-by-city-name-mail-by-id", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Message ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Message ID.", + "type": "string" + } + }, + { + "description": "Rig hint for O(1) lookup.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Rig hint for O(1) lookup.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name mail by ID" + } + }, + "/v0/city/{cityName}/mail/{id}/archive": { + "post": { + "operationId": "post-v0-city-by-city-name-mail-by-id-archive", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Message ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Message ID.", + "type": "string" + } + }, + { + "description": "Rig hint.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Rig hint.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name mail by ID archive" + } + }, + "/v0/city/{cityName}/mail/{id}/mark-unread": { + "post": { + "operationId": "post-v0-city-by-city-name-mail-by-id-mark-unread", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Message ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Message ID.", + "type": "string" + } + }, + { + "description": "Rig hint.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Rig hint.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name mail by ID mark unread" + } + }, + "/v0/city/{cityName}/mail/{id}/read": { + "post": { + "operationId": "post-v0-city-by-city-name-mail-by-id-read", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Message ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Message ID.", + "type": "string" + } + }, + { + "description": "Rig hint.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Rig hint.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name mail by ID read" + } + }, + "/v0/city/{cityName}/mail/{id}/reply": { + "post": { + "operationId": "reply-mail", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Message ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Message ID.", + "type": "string" + } + }, + { + "description": "Rig hint.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Rig hint.", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailReplyInputBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + }, + "description": "Created", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Reply to a mail message" + } + }, + "/v0/city/{cityName}/maintenance/dolt-gc": { + "post": { + "description": "Trigger a one-off maintenance cycle (dolt backup + CALL DOLT_GC + smoke test). Default async (202); ?wait=true blocks until completion (200). Returns 409 when a run is already in flight.", + "operationId": "trigger-maintenance-dolt-gc", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "When true, the handler blocks until the run completes and returns 200 with the full Run. When false (default), the handler returns 202 Accepted immediately.", + "explode": false, + "in": "query", + "name": "wait", + "schema": { + "description": "When true, the handler blocks until the run completes and returns 200 with the full Run. When false (default), the handler returns 202 Accepted immediately.", + "type": "boolean" + } + } + ], + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceTriggerBody" + } + } + }, + "description": "Accepted", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Trigger a Dolt store maintenance run" + } + }, + "/v0/city/{cityName}/maintenance/status": { + "get": { + "operationId": "get-v0-city-by-city-name-maintenance-status", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceStatusBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "format": "double", + "type": "number" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name maintenance status" + } + }, + "/v0/city/{cityName}/order/history/{bead_id}": { + "get": { + "operationId": "get-v0-city-by-city-name-order-history-by-bead-id", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Bead ID for the order run.", + "in": "path", + "name": "bead_id", + "required": true, + "schema": { + "description": "Bead ID for the order run.", + "type": "string" + } + }, + { + "description": "Store reference for disambiguating store-local bead IDs.", + "explode": false, + "in": "query", + "name": "store_ref", + "schema": { + "description": "Store reference for disambiguating store-local bead IDs.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderHistoryDetailResponse" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name order history by bead ID" + } + }, + "/v0/city/{cityName}/order/{name}": { + "get": { + "operationId": "get-v0-city-by-city-name-order-by-name", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Order name or scoped name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Order name or scoped name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderResponse" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name order by name" + } + }, + "/v0/city/{cityName}/order/{name}/disable": { + "post": { + "operationId": "post-v0-city-by-city-name-order-by-name-disable", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Order name or scoped name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Order name or scoped name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name order by name disable" + } + }, + "/v0/city/{cityName}/order/{name}/enable": { + "post": { + "operationId": "post-v0-city-by-city-name-order-by-name-enable", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Order name or scoped name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Order name or scoped name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name order by name enable" + } + }, + "/v0/city/{cityName}/order/{name}/run": { + "post": { + "operationId": "post-v0-city-by-city-name-order-by-name-run", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Order name or scoped name of a trigger=\"webhook\" order.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Order name or scoped name of a trigger=\"webhook\" order.", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderRunInputBody" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderRunOutputBody" + } + } + }, + "description": "Accepted", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name order by name run" + } + }, + "/v0/city/{cityName}/orders": { + "get": { + "operationId": "get-v0-city-by-city-name-orders", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderListBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name orders" + } + }, + "/v0/city/{cityName}/orders/check": { + "get": { + "operationId": "get-v0-city-by-city-name-orders-check", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Bypass cached order-check responses and cached order history.", + "explode": false, + "in": "query", + "name": "fresh", + "schema": { + "description": "Bypass cached order-check responses and cached order history.", + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderCheckListBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name orders check" + } + }, + "/v0/city/{cityName}/orders/feed": { + "get": { + "operationId": "get-v0-city-by-city-name-orders-feed", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Scope kind (city or rig).", + "explode": false, + "in": "query", + "name": "scope_kind", + "schema": { + "description": "Scope kind (city or rig).", + "type": "string" + } + }, + { + "description": "Scope reference.", + "explode": false, + "in": "query", + "name": "scope_ref", + "schema": { + "description": "Scope reference.", + "type": "string" + } + }, + { + "description": "Maximum number of feed items to return.", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "description": "Maximum number of feed items to return.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrdersFeedBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name orders feed" + } + }, + "/v0/city/{cityName}/orders/history": { + "get": { + "operationId": "get-v0-city-by-city-name-orders-history", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Scoped order name.", + "explode": false, + "in": "query", + "name": "scoped_name", + "required": true, + "schema": { + "description": "Scoped order name.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "Maximum number of history entries. 0 = default.", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "description": "Maximum number of history entries. 0 = default.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Return entries before this RFC3339 timestamp.", + "explode": false, + "in": "query", + "name": "before", + "schema": { + "description": "Return entries before this RFC3339 timestamp.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderHistoryListBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name orders history" + } + }, + "/v0/city/{cityName}/packs": { + "get": { + "operationId": "get-v0-city-by-city-name-packs", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackListBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name packs" + }, + "post": { + "description": "Imports a pack into the city by source (a remote git URL or registry ref), resolving + installing it so its templates compose into the city.", + "operationId": "add-pack", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackAddInputBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackAddedOutputBody" + } + } + }, + "description": "Created", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "502": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Gateway", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Add a pack" + } + }, + "/v0/city/{cityName}/packs/{name}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-packs-by-name", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "The import binding name to remove (the [imports.\u003cname\u003e] key).", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "The import binding name to remove (the [imports.\u003cname\u003e] key).", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackRemovedOutputBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name packs by name" + } + }, + "/v0/city/{cityName}/patches/agent/{base}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-patches-agent-by-base", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Agent patch name (unqualified).", + "in": "path", + "name": "base", + "required": true, + "schema": { + "description": "Agent patch name (unqualified).", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchDeletedResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name patches agent by base" + }, + "get": { + "operationId": "get-v0-city-by-city-name-patches-agent-by-base", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Agent patch name (unqualified).", + "in": "path", + "name": "base", + "required": true, + "schema": { + "description": "Agent patch name (unqualified).", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentPatch" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name patches agent by base" + } + }, + "/v0/city/{cityName}/patches/agent/{dir}/{base}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-patches-agent-by-dir-by-base", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Agent directory (rig name).", + "in": "path", + "name": "dir", + "required": true, + "schema": { + "description": "Agent directory (rig name).", + "type": "string" + } + }, + { + "description": "Agent base name.", + "in": "path", + "name": "base", + "required": true, + "schema": { + "description": "Agent base name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchDeletedResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name patches agent by dir by base" + }, + "get": { + "operationId": "get-v0-city-by-city-name-patches-agent-by-dir-by-base", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Agent directory (rig name).", + "in": "path", + "name": "dir", + "required": true, + "schema": { + "description": "Agent directory (rig name).", + "type": "string" + } + }, + { + "description": "Agent base name.", + "in": "path", + "name": "base", + "required": true, + "schema": { + "description": "Agent base name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentPatch" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name patches agent by dir by base" + } + }, + "/v0/city/{cityName}/patches/agents": { + "get": { + "operationId": "get-v0-city-by-city-name-patches-agents", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListBodyAgentPatch" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name patches agents" + }, + "put": { + "operationId": "put-v0-city-by-city-name-patches-agents", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentPatchSetInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchOKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Put v0 city by city name patches agents" + } + }, + "/v0/city/{cityName}/patches/provider/{name}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-patches-provider-by-name", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Provider patch name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Provider patch name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchDeletedResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name patches provider by name" + }, + "get": { + "operationId": "get-v0-city-by-city-name-patches-provider-by-name", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Provider patch name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Provider patch name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderPatch" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name patches provider by name" + } + }, + "/v0/city/{cityName}/patches/providers": { + "get": { + "operationId": "get-v0-city-by-city-name-patches-providers", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListBodyProviderPatch" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name patches providers" + }, + "put": { + "operationId": "put-v0-city-by-city-name-patches-providers", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderPatchSetInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchOKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Put v0 city by city name patches providers" + } + }, + "/v0/city/{cityName}/patches/rig/{name}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-patches-rig-by-name", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Rig patch name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Rig patch name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchDeletedResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name patches rig by name" + }, + "get": { + "operationId": "get-v0-city-by-city-name-patches-rig-by-name", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Rig patch name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Rig patch name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RigPatch" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name patches rig by name" + } + }, + "/v0/city/{cityName}/patches/rigs": { + "get": { + "operationId": "get-v0-city-by-city-name-patches-rigs", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListBodyRigPatch" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name patches rigs" + }, + "put": { + "operationId": "put-v0-city-by-city-name-patches-rigs", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RigPatchSetInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchOKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Put v0 city by city name patches rigs" + } + }, + "/v0/city/{cityName}/pending": { + "get": { + "operationId": "get-v0-city-by-city-name-pending", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListBodyCityPendingEntry" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -24387,7 +33397,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24395,24 +33405,13 @@ } } }, - "summary": "Post v0 city by city name mail by ID read" + "summary": "Get v0 city by city name pending" } }, - "/v0/city/{cityName}/mail/{id}/reply": { - "post": { - "operationId": "reply-mail", + "/v0/city/{cityName}/provider-readiness": { + "get": { + "operationId": "get-v0-city-by-city-name-provider-readiness", "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, { "description": "City name.", "in": "path", @@ -24426,68 +33425,88 @@ } }, { - "description": "Message ID.", - "in": "path", - "name": "id", - "required": true, + "description": "Comma-separated provider names to check (default: claude,codex,gemini).", + "explode": false, + "in": "query", + "name": "providers", "schema": { - "description": "Message ID.", + "description": "Comma-separated provider names to check (default: claude,codex,gemini).", "type": "string" } }, { - "description": "Rig hint.", + "description": "Force fresh probe, bypassing cache.", "explode": false, "in": "query", - "name": "rig", + "name": "fresh", "schema": { - "description": "Rig hint.", - "type": "string" + "description": "Force fresh probe, bypassing cache.", + "type": "boolean" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MailReplyInputBody" + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderReadinessResponse" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "201": { + "400": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Message" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "Created", + "description": "Bad Request", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Unprocessable Entity", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -24495,7 +33514,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24503,13 +33522,12 @@ } } }, - "summary": "Reply to a mail message" + "summary": "Get v0 city by city name provider readiness" } }, - "/v0/city/{cityName}/maintenance/dolt-gc": { - "post": { - "description": "Trigger a one-off maintenance cycle (dolt backup + CALL DOLT_GC + smoke test). Default async (202); ?wait=true blocks until completion (200). Returns 409 when a run is already in flight.", - "operationId": "trigger-maintenance-dolt-gc", + "/v0/city/{cityName}/provider/{name}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-provider-by-name", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -24535,33 +33553,33 @@ } }, { - "description": "When true, the handler blocks until the run completes and returns 200 with the full Run. When false (default), the handler returns 202 Accepted immediately.", - "explode": false, - "in": "query", - "name": "wait", + "description": "Provider name.", + "in": "path", + "name": "name", + "required": true, "schema": { - "description": "When true, the handler blocks until the run completes and returns 200 with the full Run. When false (default), the handler returns 202 Accepted immediately.", - "type": "boolean" + "description": "Provider name.", + "type": "string" } } ], "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceTriggerBody" + "$ref": "#/components/schemas/OKResponseBody" } } }, - "description": "Accepted", + "description": "OK", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -24569,57 +33587,44 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Trigger a Dolt store maintenance run" - } - }, - "/v0/city/{cityName}/maintenance/status": { - "get": { - "operationId": "get-v0-city-by-city-name-maintenance-status", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { + }, + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/MaintenanceStatusBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unauthorized", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { "schema": { - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Forbidden", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -24627,71 +33632,59 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name maintenance status" - } - }, - "/v0/city/{cityName}/order/history/{bead_id}": { - "get": { - "operationId": "get-v0-city-by-city-name-order-history-by-bead-id", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Bead ID for the order run.", - "in": "path", - "name": "bead_id", - "required": true, - "schema": { - "description": "Bead ID for the order run.", - "type": "string" + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "Store reference for disambiguating store-local bead IDs.", - "explode": false, - "in": "query", - "name": "store_ref", - "schema": { - "description": "Store reference for disambiguating store-local bead IDs.", - "type": "string" + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } - } - ], - "responses": { - "200": { + }, + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OrderHistoryDetailResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "501": { "content": { "application/problem+json": { "schema": { @@ -24699,7 +33692,7 @@ } } }, - "description": "Error", + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24707,12 +33700,10 @@ } } }, - "summary": "Get v0 city by city name order history by bead ID" - } - }, - "/v0/city/{cityName}/order/{name}": { + "summary": "Delete v0 city by city name provider by name" + }, "get": { - "operationId": "get-v0-city-by-city-name-order-by-name", + "operationId": "get-v0-city-by-city-name-provider-by-name", "parameters": [ { "description": "City name.", @@ -24727,12 +33718,12 @@ } }, { - "description": "Order name or scoped name.", + "description": "Provider name.", "in": "path", "name": "name", "required": true, "schema": { - "description": "Order name or scoped name.", + "description": "Provider name.", "type": "string" } } @@ -24742,18 +33733,33 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrderResponse" + "$ref": "#/components/schemas/ProviderResponse" } } }, "description": "OK", "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -24761,7 +33767,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24769,12 +33805,10 @@ } } }, - "summary": "Get v0 city by city name order by name" - } - }, - "/v0/city/{cityName}/order/{name}/disable": { - "post": { - "operationId": "post-v0-city-by-city-name-order-by-name-disable", + "summary": "Get v0 city by city name provider by name" + }, + "patch": { + "operationId": "patch-v0-city-by-city-name-provider-by-name", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -24800,16 +33834,26 @@ } }, { - "description": "Order name or scoped name.", + "description": "Provider name.", "in": "path", "name": "name", "required": true, "schema": { - "description": "Order name or scoped name.", + "description": "Provider name.", "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderUpdateInputBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { @@ -24826,7 +33870,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -24834,7 +33878,112 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24842,24 +33991,13 @@ } } }, - "summary": "Post v0 city by city name order by name disable" + "summary": "Patch v0 city by city name provider by name" } }, - "/v0/city/{cityName}/order/{name}/enable": { - "post": { - "operationId": "post-v0-city-by-city-name-order-by-name-enable", + "/v0/city/{cityName}/providers": { + "get": { + "operationId": "get-v0-city-by-city-name-providers", "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, { "description": "City name.", "in": "path", @@ -24871,16 +34009,6 @@ "pattern": "\\S", "type": "string" } - }, - { - "description": "Order name or scoped name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Order name or scoped name.", - "type": "string" - } } ], "responses": { @@ -24888,18 +34016,33 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ListBodyProviderResponse" } } }, "description": "OK", "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -24907,7 +34050,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24915,12 +34088,10 @@ } } }, - "summary": "Post v0 city by city name order by name enable" - } - }, - "/v0/city/{cityName}/order/{name}/run": { + "summary": "Get v0 city by city name providers" + }, "post": { - "operationId": "post-v0-city-by-city-name-order-by-name-run", + "operationId": "create-provider", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -24944,45 +34115,35 @@ "pattern": "\\S", "type": "string" } - }, - { - "description": "Order name or scoped name of a trigger=\"webhook\" order.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Order name or scoped name of a trigger=\"webhook\" order.", - "type": "string" - } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrderRunInputBody" + "$ref": "#/components/schemas/ProviderCreateInputBody" } } }, "required": true }, "responses": { - "202": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrderRunOutputBody" + "$ref": "#/components/schemas/ProviderCreatedOutputBody" } } }, - "description": "Accepted", + "description": "Created", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -24990,51 +34151,29 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name order by name run" - } - }, - "/v0/city/{cityName}/orders": { - "get": { - "operationId": "get-v0-city-by-city-name-orders", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { + }, + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OrderListBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "403": { "content": { "application/problem+json": { "schema": { @@ -25042,61 +34181,29 @@ } } }, - "description": "Error", + "description": "Forbidden", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name orders" - } - }, - "/v0/city/{cityName}/orders/check": { - "get": { - "operationId": "get-v0-city-by-city-name-orders-check", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Bypass cached order-check responses and cached order history.", - "explode": false, - "in": "query", - "name": "fresh", - "schema": { - "description": "Bypass cached order-check responses and cached order history.", - "type": "boolean" - } - } - ], - "responses": { - "200": { + "404": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OrderCheckListBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "409": { "content": { "application/problem+json": { "schema": { @@ -25104,83 +34211,44 @@ } } }, - "description": "Error", + "description": "Conflict", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name orders check" - } - }, - "/v0/city/{cityName}/orders/feed": { - "get": { - "operationId": "get-v0-city-by-city-name-orders-feed", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Scope kind (city or rig).", - "explode": false, - "in": "query", - "name": "scope_kind", - "schema": { - "description": "Scope kind (city or rig).", - "type": "string" - } }, - { - "description": "Scope reference.", - "explode": false, - "in": "query", - "name": "scope_ref", - "schema": { - "description": "Scope reference.", - "type": "string" + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "Maximum number of feed items to return.", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "description": "Maximum number of feed items to return.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - } - ], - "responses": { - "200": { + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OrdersFeedBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "501": { "content": { "application/problem+json": { "schema": { @@ -25188,7 +34256,7 @@ } } }, - "description": "Error", + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -25196,12 +34264,12 @@ } } }, - "summary": "Get v0 city by city name orders feed" + "summary": "Create a provider" } }, - "/v0/city/{cityName}/orders/history": { + "/v0/city/{cityName}/providers/public": { "get": { - "operationId": "get-v0-city-by-city-name-orders-history", + "operationId": "get-v0-city-by-city-name-providers-public", "parameters": [ { "description": "City name.", @@ -25214,40 +34282,6 @@ "pattern": "\\S", "type": "string" } - }, - { - "description": "Scoped order name.", - "explode": false, - "in": "query", - "name": "scoped_name", - "required": true, - "schema": { - "description": "Scoped order name.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "Maximum number of history entries. 0 = default.", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "description": "Maximum number of history entries. 0 = default.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, - { - "description": "Return entries before this RFC3339 timestamp.", - "explode": false, - "in": "query", - "name": "before", - "schema": { - "description": "Return entries before this RFC3339 timestamp.", - "type": "string" - } } ], "responses": { @@ -25255,17 +34289,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrderHistoryListBody" + "$ref": "#/components/schemas/ProviderPublicListBody" } } }, "description": "OK", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Index": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" } }, "X-GC-Request-Id": { @@ -25273,7 +34308,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -25281,7 +34316,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -25289,12 +34354,12 @@ } } }, - "summary": "Get v0 city by city name orders history" + "summary": "Get v0 city by city name providers public" } }, - "/v0/city/{cityName}/packs": { + "/v0/city/{cityName}/readiness": { "get": { - "operationId": "get-v0-city-by-city-name-packs", + "operationId": "get-v0-city-by-city-name-readiness", "parameters": [ { "description": "City name.", @@ -25302,11 +34367,31 @@ "name": "cityName", "required": true, "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Comma-separated readiness items to check (default: claude,codex,gemini,github_cli).", + "explode": false, + "in": "query", + "name": "items", + "schema": { + "description": "Comma-separated readiness items to check (default: claude,codex,gemini,github_cli).", "type": "string" } + }, + { + "description": "Force fresh probe, bypassing cache.", + "explode": false, + "in": "query", + "name": "fresh", + "schema": { + "description": "Force fresh probe, bypassing cache.", + "type": "boolean" + } } ], "responses": { @@ -25314,7 +34399,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackListBody" + "$ref": "#/components/schemas/ReadinessResponse" } } }, @@ -25325,7 +34410,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -25333,71 +34418,44 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name packs" - }, - "post": { - "description": "Imports a pack into the city by source (a remote git URL or registry ref), resolving + installing it so its templates compose into the city.", - "operationId": "add-pack", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PackAddInputBody" + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "201": { + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/PackAddedOutputBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "Created", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -25405,7 +34463,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -25413,12 +34471,12 @@ } } }, - "summary": "Add a pack" + "summary": "Get v0 city by city name readiness" } }, - "/v0/city/{cityName}/packs/{name}": { + "/v0/city/{cityName}/rig/{name}": { "delete": { - "operationId": "delete-v0-city-by-city-name-packs-by-name", + "operationId": "delete-v0-city-by-city-name-rig-by-name", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -25444,12 +34502,12 @@ } }, { - "description": "The import binding name to remove (the [imports.\u003cname\u003e] key).", + "description": "Rig name.", "in": "path", "name": "name", "required": true, "schema": { - "description": "The import binding name to remove (the [imports.\u003cname\u003e] key).", + "description": "Rig name.", "type": "string" } } @@ -25459,7 +34517,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackRemovedOutputBody" + "$ref": "#/components/schemas/OKResponseBody" } } }, @@ -25470,7 +34528,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -25478,72 +34536,29 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Delete v0 city by city name packs by name" - } - }, - "/v0/city/{cityName}/patches/agent/{base}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-patches-agent-by-base", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Agent patch name (unqualified).", - "in": "path", - "name": "base", - "required": true, - "schema": { - "description": "Agent patch name (unqualified).", - "type": "string" - } - } - ], - "responses": { - "200": { + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/PatchDeletedResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "403": { "content": { "application/problem+json": { "schema": { @@ -25551,74 +34566,29 @@ } } }, - "description": "Error", + "description": "Forbidden", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Delete v0 city by city name patches agent by base" - }, - "get": { - "operationId": "get-v0-city-by-city-name-patches-agent-by-base", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Agent patch name (unqualified).", - "in": "path", - "name": "base", - "required": true, - "schema": { - "description": "Agent patch name (unqualified).", - "type": "string" - } - } - ], - "responses": { - "200": { + "404": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/AgentPatch" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Not Found", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "422": { "content": { "application/problem+json": { "schema": { @@ -25626,82 +34596,29 @@ } } }, - "description": "Error", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name patches agent by base" - } - }, - "/v0/city/{cityName}/patches/agent/{dir}/{base}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-patches-agent-by-dir-by-base", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Agent directory (rig name).", - "in": "path", - "name": "dir", - "required": true, - "schema": { - "description": "Agent directory (rig name).", - "type": "string" - } }, - { - "description": "Agent base name.", - "in": "path", - "name": "base", - "required": true, - "schema": { - "description": "Agent base name.", - "type": "string" - } - } - ], - "responses": { - "200": { + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/PatchDeletedResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "501": { "content": { "application/problem+json": { "schema": { @@ -25709,7 +34626,7 @@ } } }, - "description": "Error", + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -25717,10 +34634,10 @@ } } }, - "summary": "Delete v0 city by city name patches agent by dir by base" + "summary": "Delete v0 city by city name rig by name" }, "get": { - "operationId": "get-v0-city-by-city-name-patches-agent-by-dir-by-base", + "operationId": "get-v0-city-by-city-name-rig-by-name", "parameters": [ { "description": "City name.", @@ -25735,23 +34652,23 @@ } }, { - "description": "Agent directory (rig name).", + "description": "Rig name.", "in": "path", - "name": "dir", + "name": "name", "required": true, "schema": { - "description": "Agent directory (rig name).", + "description": "Rig name.", "type": "string" } }, { - "description": "Agent base name.", - "in": "path", - "name": "base", - "required": true, + "description": "Include git status.", + "explode": false, + "in": "query", + "name": "git", "schema": { - "description": "Agent base name.", - "type": "string" + "description": "Include git status.", + "type": "boolean" } } ], @@ -25760,7 +34677,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AgentPatch" + "$ref": "#/components/schemas/RigResponse" } } }, @@ -25786,7 +34703,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -25794,66 +34711,14 @@ } } }, - "description": "Error", - "headers": { - "X-GC-Request-Id": { - "$ref": "#/components/headers/X-GC-Request-Id" - } - } - } - }, - "summary": "Get v0 city by city name patches agent by dir by base" - } - }, - "/v0/city/{cityName}/patches/agents": { - "get": { - "operationId": "get-v0-city-by-city-name-patches-agents", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListBodyAgentPatch" - } - } - }, - "description": "OK", + "description": "Not Found", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "422": { "content": { "application/problem+json": { "schema": { @@ -25861,70 +34726,14 @@ } } }, - "description": "Error", - "headers": { - "X-GC-Request-Id": { - "$ref": "#/components/headers/X-GC-Request-Id" - } - } - } - }, - "summary": "Get v0 city by city name patches agents" - }, - "put": { - "operationId": "put-v0-city-by-city-name-patches-agents", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentPatchSetInputBody" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchOKResponseBody" - } - } - }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -25932,7 +34741,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -25940,12 +34749,10 @@ } } }, - "summary": "Put v0 city by city name patches agents" - } - }, - "/v0/city/{cityName}/patches/provider/{name}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-patches-provider-by-name", + "summary": "Get v0 city by city name rig by name" + }, + "patch": { + "operationId": "patch-v0-city-by-city-name-rig-by-name", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -25971,22 +34778,32 @@ } }, { - "description": "Provider patch name.", + "description": "Rig name.", "in": "path", "name": "name", "required": true, "schema": { - "description": "Provider patch name.", + "description": "Rig name.", "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RigUpdateInputBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchDeletedResponseBody" + "$ref": "#/components/schemas/OKResponseBody" } } }, @@ -25997,7 +34814,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -26005,74 +34822,14 @@ } } }, - "description": "Error", - "headers": { - "X-GC-Request-Id": { - "$ref": "#/components/headers/X-GC-Request-Id" - } - } - } - }, - "summary": "Delete v0 city by city name patches provider by name" - }, - "get": { - "operationId": "get-v0-city-by-city-name-patches-provider-by-name", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Provider patch name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Provider patch name.", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProviderPatch" - } - } - }, - "description": "OK", + "description": "Bad Request", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -26080,66 +34837,29 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name patches provider by name" - } - }, - "/v0/city/{cityName}/patches/providers": { - "get": { - "operationId": "get-v0-city-by-city-name-patches-providers", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { + }, + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ListBodyProviderPatch" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Forbidden", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -26147,70 +34867,44 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name patches providers" - }, - "put": { - "operationId": "put-v0-city-by-city-name-patches-providers", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProviderPatchSetInputBody" + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "200": { + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/PatchOKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "501": { "content": { "application/problem+json": { "schema": { @@ -26218,7 +34912,7 @@ } } }, - "description": "Error", + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26226,12 +34920,12 @@ } } }, - "summary": "Put v0 city by city name patches providers" + "summary": "Patch v0 city by city name rig by name" } }, - "/v0/city/{cityName}/patches/rig/{name}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-patches-rig-by-name", + "/v0/city/{cityName}/rig/{name}/{action}": { + "post": { + "operationId": "post-v0-city-by-city-name-rig-by-name-by-action", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -26257,12 +34951,27 @@ } }, { - "description": "Rig patch name.", + "description": "Rig name.", "in": "path", "name": "name", "required": true, "schema": { - "description": "Rig patch name.", + "description": "Rig name.", + "type": "string" + } + }, + { + "description": "Action to perform.", + "in": "path", + "name": "action", + "required": true, + "schema": { + "description": "Action to perform.", + "enum": [ + "suspend", + "resume", + "restart" + ], "type": "string" } } @@ -26272,7 +34981,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchDeletedResponseBody" + "$ref": "#/components/schemas/RigActionBody" } } }, @@ -26283,7 +34992,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -26291,7 +35000,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26299,10 +35083,12 @@ } } }, - "summary": "Delete v0 city by city name patches rig by name" - }, + "summary": "Post v0 city by city name rig by name by action" + } + }, + "/v0/city/{cityName}/rigs": { "get": { - "operationId": "get-v0-city-by-city-name-patches-rig-by-name", + "operationId": "get-v0-city-by-city-name-rigs", "parameters": [ { "description": "City name.", @@ -26317,14 +35103,34 @@ } }, { - "description": "Rig patch name.", - "in": "path", - "name": "name", - "required": true, + "description": "Event sequence number; when provided, blocks until a newer event arrives.", + "explode": false, + "in": "query", + "name": "index", "schema": { - "description": "Rig patch name.", + "description": "Event sequence number; when provided, blocks until a newer event arrives.", + "type": "string" + } + }, + { + "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", + "explode": false, + "in": "query", + "name": "wait", + "schema": { + "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", "type": "string" } + }, + { + "description": "Include git status.", + "explode": false, + "in": "query", + "name": "git", + "schema": { + "description": "Include git status.", + "type": "boolean" + } } ], "responses": { @@ -26332,7 +35138,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RigPatch" + "$ref": "#/components/schemas/ListBodyRigResponse" } } }, @@ -26358,7 +35164,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -26366,66 +35172,44 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name patches rig by name" - } - }, - "/v0/city/{cityName}/patches/rigs": { - "get": { - "operationId": "get-v0-city-by-city-name-patches-rigs", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { + }, + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ListBodyRigPatch" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Internal Server Error", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -26433,7 +35217,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26441,10 +35225,10 @@ } } }, - "summary": "Get v0 city by city name patches rigs" + "summary": "Get v0 city by city name rigs" }, - "put": { - "operationId": "put-v0-city-by-city-name-patches-rigs", + "post": { + "operationId": "create-rig", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -26474,29 +35258,29 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RigPatchSetInputBody" + "$ref": "#/components/schemas/RigCreateInputBody" } } }, "required": true }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchOKResponseBody" + "$ref": "#/components/schemas/RigCreatedOutputBody" } } }, - "description": "OK", + "description": "Created", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -26504,7 +35288,112 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26512,12 +35401,12 @@ } } }, - "summary": "Put v0 city by city name patches rigs" + "summary": "Create a rig" } }, - "/v0/city/{cityName}/pending": { + "/v0/city/{cityName}/service/{name}": { "get": { - "operationId": "get-v0-city-by-city-name-pending", + "operationId": "get-v0-city-by-city-name-service-by-name", "parameters": [ { "description": "City name.", @@ -26530,6 +35419,16 @@ "pattern": "\\S", "type": "string" } + }, + { + "description": "Service name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Service name.", + "type": "string" + } } ], "responses": { @@ -26537,7 +35436,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListBodyCityPendingEntry" + "$ref": "#/components/schemas/Status" } } }, @@ -26563,7 +35462,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -26571,71 +35470,29 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name pending" - } - }, - "/v0/city/{cityName}/provider-readiness": { - "get": { - "operationId": "get-v0-city-by-city-name-provider-readiness", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Comma-separated provider names to check (default: claude,codex,gemini).", - "explode": false, - "in": "query", - "name": "providers", - "schema": { - "description": "Comma-separated provider names to check (default: claude,codex,gemini).", - "type": "string" - } }, - { - "description": "Force fresh probe, bypassing cache.", - "explode": false, - "in": "query", - "name": "fresh", - "schema": { - "description": "Force fresh probe, bypassing cache.", - "type": "boolean" - } - } - ], - "responses": { - "200": { + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProviderReadinessResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -26643,7 +35500,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26651,12 +35508,12 @@ } } }, - "summary": "Get v0 city by city name provider readiness" + "summary": "Get v0 city by city name service by name" } }, - "/v0/city/{cityName}/provider/{name}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-provider-by-name", + "/v0/city/{cityName}/service/{name}/restart": { + "post": { + "operationId": "post-v0-city-by-city-name-service-by-name-restart", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -26682,12 +35539,12 @@ } }, { - "description": "Provider name.", + "description": "Service name.", "in": "path", "name": "name", "required": true, "schema": { - "description": "Provider name.", + "description": "Service name.", "type": "string" } } @@ -26697,18 +35554,78 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ServiceRestartOutputBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -26716,7 +35633,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26724,10 +35641,12 @@ } } }, - "summary": "Delete v0 city by city name provider by name" - }, + "summary": "Post v0 city by city name service by name restart" + } + }, + "/v0/city/{cityName}/services": { "get": { - "operationId": "get-v0-city-by-city-name-provider-by-name", + "operationId": "get-v0-city-by-city-name-services", "parameters": [ { "description": "City name.", @@ -26740,16 +35659,6 @@ "pattern": "\\S", "type": "string" } - }, - { - "description": "Provider name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Provider name.", - "type": "string" - } } ], "responses": { @@ -26757,7 +35666,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProviderResponse" + "$ref": "#/components/schemas/ListBodyStatus" } } }, @@ -26783,7 +35692,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -26791,80 +35700,29 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name provider by name" - }, - "patch": { - "operationId": "patch-v0-city-by-city-name-provider-by-name", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Provider name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Provider name.", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProviderUpdateInputBody" - } - } }, - "required": true - }, - "responses": { - "200": { + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -26872,7 +35730,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26880,12 +35738,12 @@ } } }, - "summary": "Patch v0 city by city name provider by name" + "summary": "Get v0 city by city name services" } }, - "/v0/city/{cityName}/providers": { + "/v0/city/{cityName}/session/{id}": { "get": { - "operationId": "get-v0-city-by-city-name-providers", + "operationId": "get-v0-city-by-city-name-session-by-id", "parameters": [ { "description": "City name.", @@ -26898,6 +35756,39 @@ "pattern": "\\S", "type": "string" } + }, + { + "description": "Session ID, alias, or runtime session_name.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Session ID, alias, or runtime session_name.", + "type": "string" + } + }, + { + "description": "Include last output preview.", + "explode": false, + "in": "query", + "name": "peek", + "schema": { + "description": "Include last output preview.", + "type": "boolean" + } + }, + { + "description": "Number of lines to include in the last output preview when peek=true. Defaults to 5.", + "explode": false, + "in": "query", + "name": "peek_lines", + "schema": { + "description": "Number of lines to include in the last output preview when peek=true. Defaults to 5.", + "format": "int64", + "maximum": 10000, + "minimum": 0, + "type": "integer" + } } ], "responses": { @@ -26905,7 +35796,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListBodyProviderResponse" + "$ref": "#/components/schemas/SessionResponse" } } }, @@ -26931,7 +35822,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -26939,7 +35830,67 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26947,10 +35898,10 @@ } } }, - "summary": "Get v0 city by city name providers" + "summary": "Get v0 city by city name session by ID" }, - "post": { - "operationId": "create-provider", + "patch": { + "operationId": "patch-v0-city-by-city-name-session-by-id", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -26974,35 +35925,60 @@ "pattern": "\\S", "type": "string" } + }, + { + "description": "Session ID, alias, or runtime session_name.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Session ID, alias, or runtime session_name.", + "type": "string" + } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProviderCreateInputBody" + "$ref": "#/components/schemas/SessionPatchBody" } } }, "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProviderCreatedOutputBody" + "$ref": "#/components/schemas/SessionResponse" } } }, - "description": "Created", + "description": "OK", "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -27010,59 +35986,44 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Create a provider" - } - }, - "/v0/city/{cityName}/providers/public": { - "get": { - "operationId": "get-v0-city-by-city-name-providers-public", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProviderPublicListBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Forbidden", "headers": { - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -27070,71 +36031,29 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name providers public" - } - }, - "/v0/city/{cityName}/readiness": { - "get": { - "operationId": "get-v0-city-by-city-name-readiness", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Comma-separated readiness items to check (default: claude,codex,gemini,github_cli).", - "explode": false, - "in": "query", - "name": "items", - "schema": { - "description": "Comma-separated readiness items to check (default: claude,codex,gemini,github_cli).", - "type": "string" - } }, - { - "description": "Force fresh probe, bypassing cache.", - "explode": false, - "in": "query", - "name": "fresh", - "schema": { - "description": "Force fresh probe, bypassing cache.", - "type": "boolean" - } - } - ], - "responses": { - "200": { + "409": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ReadinessResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Conflict", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "422": { "content": { "application/problem+json": { "schema": { @@ -27142,72 +36061,29 @@ } } }, - "description": "Error", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name readiness" - } - }, - "/v0/city/{cityName}/rig/{name}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-rig-by-name", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Rig name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Rig name.", - "type": "string" - } - } - ], - "responses": { - "200": { + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -27215,7 +36091,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -27223,10 +36099,12 @@ } } }, - "summary": "Delete v0 city by city name rig by name" - }, + "summary": "Patch v0 city by city name session by ID" + } + }, + "/v0/city/{cityName}/session/{id}/agents": { "get": { - "operationId": "get-v0-city-by-city-name-rig-by-name", + "operationId": "get-v0-city-by-city-name-session-by-id-agents", "parameters": [ { "description": "City name.", @@ -27241,24 +36119,14 @@ } }, { - "description": "Rig name.", + "description": "Session ID, alias, or runtime session_name.", "in": "path", - "name": "name", + "name": "id", "required": true, "schema": { - "description": "Rig name.", + "description": "Session ID, alias, or runtime session_name.", "type": "string" } - }, - { - "description": "Include git status.", - "explode": false, - "in": "query", - "name": "git", - "schema": { - "description": "Include git status.", - "type": "boolean" - } } ], "responses": { @@ -27266,7 +36134,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RigResponse" + "$ref": "#/components/schemas/SessionAgentListResponse" } } }, @@ -27292,7 +36160,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -27300,80 +36168,29 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name rig by name" - }, - "patch": { - "operationId": "patch-v0-city-by-city-name-rig-by-name", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Rig name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Rig name.", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RigUpdateInputBody" - } - } }, - "required": true - }, - "responses": { - "200": { + "409": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Conflict", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "422": { "content": { "application/problem+json": { "schema": { @@ -27381,82 +36198,29 @@ } } }, - "description": "Error", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Patch v0 city by city name rig by name" - } - }, - "/v0/city/{cityName}/rig/{name}/{action}": { - "post": { - "operationId": "post-v0-city-by-city-name-rig-by-name-by-action", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Rig name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Rig name.", - "type": "string" - } - }, - { - "description": "Action to perform (suspend, resume, restart).", - "in": "path", - "name": "action", - "required": true, - "schema": { - "description": "Action to perform (suspend, resume, restart).", - "type": "string" - } - } - ], - "responses": { - "200": { + }, + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/RigActionBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -27464,7 +36228,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -27472,12 +36236,12 @@ } } }, - "summary": "Post v0 city by city name rig by name by action" + "summary": "Get v0 city by city name session by ID agents" } }, - "/v0/city/{cityName}/rigs": { + "/v0/city/{cityName}/session/{id}/agents/{agentId}": { "get": { - "operationId": "get-v0-city-by-city-name-rigs", + "operationId": "get-v0-city-by-city-name-session-by-id-agents-by-agent-id", "parameters": [ { "description": "City name.", @@ -27492,34 +36256,24 @@ } }, { - "description": "Event sequence number; when provided, blocks until a newer event arrives.", - "explode": false, - "in": "query", - "name": "index", + "description": "Session ID, alias, or runtime session_name.", + "in": "path", + "name": "id", + "required": true, "schema": { - "description": "Event sequence number; when provided, blocks until a newer event arrives.", + "description": "Session ID, alias, or runtime session_name.", "type": "string" } }, { - "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", - "explode": false, - "in": "query", - "name": "wait", + "description": "Subagent ID within the session.", + "in": "path", + "name": "agentId", + "required": true, "schema": { - "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", + "description": "Subagent ID within the session.", "type": "string" } - }, - { - "description": "Include git status.", - "explode": false, - "in": "query", - "name": "git", - "schema": { - "description": "Include git status.", - "type": "boolean" - } } ], "responses": { @@ -27527,7 +36281,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListBodyRigResponse" + "$ref": "#/components/schemas/SessionAgentGetResponse" } } }, @@ -27553,7 +36307,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -27561,70 +36315,74 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name rigs" - }, - "post": { - "operationId": "create-rig", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RigCreateInputBody" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "201": { + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/RigCreatedOutputBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "Created", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -27632,7 +36390,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -27640,13 +36398,24 @@ } } }, - "summary": "Create a rig" + "summary": "Get v0 city by city name session by ID agents by agent ID" } }, - "/v0/city/{cityName}/service/{name}": { - "get": { - "operationId": "get-v0-city-by-city-name-service-by-name", + "/v0/city/{cityName}/session/{id}/close": { + "post": { + "operationId": "post-v0-city-by-city-name-session-by-id-close", "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, { "description": "City name.", "in": "path", @@ -27660,14 +36429,24 @@ } }, { - "description": "Service name.", + "description": "Session ID, alias, or runtime session_name.", "in": "path", - "name": "name", + "name": "id", "required": true, "schema": { - "description": "Service name.", + "description": "Session ID, alias, or runtime session_name.", "type": "string" } + }, + { + "description": "Permanently delete bead after closing.", + "explode": false, + "in": "query", + "name": "delete", + "schema": { + "description": "Permanently delete bead after closing.", + "type": "boolean" + } } ], "responses": { @@ -27675,33 +36454,48 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Status" + "$ref": "#/components/schemas/OKResponseBody" } } }, "description": "OK", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Forbidden", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -27709,7 +36503,67 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -27717,12 +36571,12 @@ } } }, - "summary": "Get v0 city by city name service by name" + "summary": "Post v0 city by city name session by ID close" } }, - "/v0/city/{cityName}/service/{name}/restart": { + "/v0/city/{cityName}/session/{id}/kill": { "post": { - "operationId": "post-v0-city-by-city-name-service-by-name-restart", + "operationId": "post-v0-city-by-city-name-session-by-id-kill", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -27748,12 +36602,12 @@ } }, { - "description": "Service name.", + "description": "Session ID, alias, or runtime session_name.", "in": "path", - "name": "name", + "name": "id", "required": true, "schema": { - "description": "Service name.", + "description": "Session ID, alias, or runtime session_name.", "type": "string" } } @@ -27763,7 +36617,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ServiceRestartOutputBody" + "$ref": "#/components/schemas/OKWithIDResponseBody" } } }, @@ -27774,7 +36628,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -27782,66 +36636,29 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name service by name restart" - } - }, - "/v0/city/{cityName}/services": { - "get": { - "operationId": "get-v0-city-by-city-name-services", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { + }, + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ListBodyStatus" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Forbidden", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -27849,99 +36666,59 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name services" - } - }, - "/v0/city/{cityName}/session/{id}": { - "get": { - "operationId": "get-v0-city-by-city-name-session-by-id", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Session ID, alias, or runtime session_name.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Session ID, alias, or runtime session_name.", - "type": "string" - } - }, - { - "description": "Include last output preview.", - "explode": false, - "in": "query", - "name": "peek", - "schema": { - "description": "Include last output preview.", - "type": "boolean" - } }, - { - "description": "Number of lines to include in the last output preview when peek=true. Defaults to 5.", - "explode": false, - "in": "query", - "name": "peek_lines", - "schema": { - "description": "Number of lines to include in the last output preview when peek=true. Defaults to 5.", - "format": "int64", - "maximum": 10000, - "minimum": 0, - "type": "integer" - } - } - ], - "responses": { - "200": { + "409": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/SessionResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Conflict", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Internal Server Error", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -27949,7 +36726,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -27957,10 +36734,12 @@ } } }, - "summary": "Get v0 city by city name session by ID" - }, - "patch": { - "operationId": "patch-v0-city-by-city-name-session-by-id", + "summary": "Post v0 city by city name session by ID kill" + } + }, + "/v0/city/{cityName}/session/{id}/messages": { + "post": { + "operationId": "send-session-message", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -28000,44 +36779,59 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionPatchBody" + "$ref": "#/components/schemas/SessionMessageInputBody" } } }, "required": true }, "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionResponse" + "$ref": "#/components/schemas/AsyncAcceptedBody" } } }, - "description": "OK", + "description": "Accepted", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Forbidden", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -28045,76 +36839,44 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Patch v0 city by city name session by ID" - } - }, - "/v0/city/{cityName}/session/{id}/agents": { - "get": { - "operationId": "get-v0-city-by-city-name-session-by-id-agents", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Session ID, alias, or runtime session_name.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Session ID, alias, or runtime session_name.", - "type": "string" + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } - } - ], - "responses": { - "200": { + }, + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/SessionAgentListResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -28122,7 +36884,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -28130,12 +36892,12 @@ } } }, - "summary": "Get v0 city by city name session by ID agents" + "summary": "Send a message to a session" } }, - "/v0/city/{cityName}/session/{id}/agents/{agentId}": { + "/v0/city/{cityName}/session/{id}/pending": { "get": { - "operationId": "get-v0-city-by-city-name-session-by-id-agents-by-agent-id", + "operationId": "get-v0-city-by-city-name-session-by-id-pending", "parameters": [ { "description": "City name.", @@ -28158,16 +36920,6 @@ "description": "Session ID, alias, or runtime session_name.", "type": "string" } - }, - { - "description": "Subagent ID within the session.", - "in": "path", - "name": "agentId", - "required": true, - "schema": { - "description": "Subagent ID within the session.", - "type": "string" - } } ], "responses": { @@ -28175,7 +36927,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionAgentGetResponse" + "$ref": "#/components/schemas/SessionPendingResponse" } } }, @@ -28201,7 +36953,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -28209,82 +36961,29 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name session by ID agents by agent ID" - } - }, - "/v0/city/{cityName}/session/{id}/close": { - "post": { - "operationId": "post-v0-city-by-city-name-session-by-id-close", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Session ID, alias, or runtime session_name.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Session ID, alias, or runtime session_name.", - "type": "string" - } }, - { - "description": "Permanently delete bead after closing.", - "explode": false, - "in": "query", - "name": "delete", - "schema": { - "description": "Permanently delete bead after closing.", - "type": "boolean" - } - } - ], - "responses": { - "200": { + "409": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Conflict", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "422": { "content": { "application/problem+json": { "schema": { @@ -28292,72 +36991,29 @@ } } }, - "description": "Error", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name session by ID close" - } - }, - "/v0/city/{cityName}/session/{id}/kill": { - "post": { - "operationId": "post-v0-city-by-city-name-session-by-id-kill", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Session ID, alias, or runtime session_name.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Session ID, alias, or runtime session_name.", - "type": "string" - } - } - ], - "responses": { - "200": { + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKWithIDResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -28365,7 +37021,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -28373,12 +37029,12 @@ } } }, - "summary": "Post v0 city by city name session by ID kill" + "summary": "Get v0 city by city name session by ID pending" } }, - "/v0/city/{cityName}/session/{id}/messages": { + "/v0/city/{cityName}/session/{id}/permission-mode": { "post": { - "operationId": "send-session-message", + "operationId": "post-v0-city-by-city-name-session-by-id-permission-mode", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -28418,29 +37074,44 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionMessageInputBody" + "$ref": "#/components/schemas/SessionPermissionModeBody" } } }, "required": true }, "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AsyncAcceptedBody" + "$ref": "#/components/schemas/SessionResponse" } } }, - "description": "Accepted", + "description": "OK", "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -28448,76 +37119,119 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Send a message to a session" - } - }, - "/v0/city/{cityName}/session/{id}/pending": { - "get": { - "operationId": "get-v0-city-by-city-name-session-by-id-pending", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "Session ID, alias, or runtime session_name.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Session ID, alias, or runtime session_name.", - "type": "string" + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } - } - ], - "responses": { - "200": { + }, + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/SessionPendingResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Not Implemented", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -28525,7 +37239,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -28533,12 +37247,12 @@ } } }, - "summary": "Get v0 city by city name session by ID pending" + "summary": "Post v0 city by city name session by ID permission mode" } }, - "/v0/city/{cityName}/session/{id}/permission-mode": { + "/v0/city/{cityName}/session/{id}/rename": { "post": { - "operationId": "post-v0-city-by-city-name-session-by-id-permission-mode", + "operationId": "post-v0-city-by-city-name-session-by-id-rename", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -28578,7 +37292,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionPermissionModeBody" + "$ref": "#/components/schemas/SessionRenameInputBody" } } }, @@ -28615,7 +37329,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -28623,7 +37337,112 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -28631,12 +37450,12 @@ } } }, - "summary": "Post v0 city by city name session by ID permission mode" + "summary": "Post v0 city by city name session by ID rename" } }, - "/v0/city/{cityName}/session/{id}/rename": { + "/v0/city/{cityName}/session/{id}/respond": { "post": { - "operationId": "post-v0-city-by-city-name-session-by-id-rename", + "operationId": "respond-session", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -28676,44 +37495,59 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionRenameInputBody" + "$ref": "#/components/schemas/SessionRespondInputBody" } } }, "required": true }, "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionResponse" + "$ref": "#/components/schemas/SessionRespondOutputBody" } } }, - "description": "OK", + "description": "Accepted", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Forbidden", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -28721,82 +37555,74 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name session by ID rename" - } - }, - "/v0/city/{cityName}/session/{id}/respond": { - "post": { - "operationId": "respond-session", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "Session ID, alias, or runtime session_name.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Session ID, alias, or runtime session_name.", - "type": "string" + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SessionRespondInputBody" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "202": { + "501": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/SessionRespondOutputBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "Accepted", + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -28804,7 +37630,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -28869,7 +37695,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -28877,7 +37703,97 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29174,7 +38090,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -29182,7 +38098,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29247,7 +38238,97 @@ } } }, - "default": { + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { "content": { "application/problem+json": { "schema": { @@ -29255,7 +38336,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29364,7 +38445,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -29372,7 +38453,67 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29437,7 +38578,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -29445,7 +38586,112 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29556,7 +38802,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -29564,7 +38810,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29609,25 +38900,100 @@ } } }, - "required": true - }, - "responses": { - "202": { + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncAcceptedBody" + } + } + }, + "description": "Accepted", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/AsyncAcceptedBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "Accepted", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -29635,7 +39001,22 @@ } } }, - "description": "Error", + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29700,7 +39081,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -29708,7 +39089,97 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29797,7 +39268,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -29805,7 +39276,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29961,7 +39477,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -29969,7 +39485,82 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -30056,7 +39647,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -30064,7 +39655,52 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" diff --git a/engdocs/architecture/api-control-plane.md b/engdocs/architecture/api-control-plane.md index 01e062860f..f8fba01d5e 100644 --- a/engdocs/architecture/api-control-plane.md +++ b/engdocs/architecture/api-control-plane.md @@ -436,6 +436,37 @@ Huma enters the stack), error bodies are pre-serialized per well-known error, no runtime `json.Marshal`. The constants live in `internal/api/middleware.go` as `problemBody` values. +**Machine-readable codes: the `apierr` registry.** Every error carries a +stable machine identity — an RFC 9457 `type` URN (`urn:gascity:error:`) +plus a convenience `code` member — so an autonomous consumer branches on a +registered identifier instead of parsing `detail` prose. `internal/api/apierr` +is the single source of truth, mirroring the typed-events registry +(`events.RegisterPayload`): a central catalog (`apierr/catalog.go`) of +`ProblemType{Code,Status,Title}` values, minted through the constructors +(`apierr.BeadNotFound.Msg(...)`, `.With(...)`) so the URN can never drift from a +registered code. `apierr.ErrorModel` embeds `huma.ErrorModel` and adds +`code,omitempty`; the Go type is named `ErrorModel` so the OpenAPI schema keeps +that name (no genclient/TS churn). + +`errors_install.go` overrides `huma.NewError` at package-init so *every* error — +including Huma's own request-validation failures — becomes an +`*apierr.ErrorModel`. Huma's built-in 422 (`"validation failed"`) is the one +auto-stamped fallback (`validation-failed`); every other error Huma constructs +is wrapped verbatim with an empty (omitted) `code`, byte-identical on the wire, +where absence of a code marks an as-yet-unconverted legacy path. Because +`defineErrors` derives the error schema from `NewError`, `apierr.ErrorModel` is +the sole error schema for the whole API; `documentProblemTypes` publishes the +catalog as `x-gascity-problem-types` on `ErrorModel.type`. + +Operations opt into an enumerated error contract with `errorStatuses(...)` (or +`Operation.Errors`), which turns their catch-all `default` response into one +problem+json response per status (Huma auto-appends 422/500). The bead and sling +endpoints are the first such pilot. Two CI guards keep it honest: +`TestEveryEmittedErrorCodeIsRegistered` (no `urn:gascity:error:` literal outside +`apierr/`; every emitted URN resolves in the registry — the analog of +`TestEveryKnownEventTypeHasRegisteredPayload`) and `TestErrorModelSpecProjection` +(the published `x-gascity-problem-types` equals the sorted registry). + ### 3.9 The carved-out non-typed paths Four surfaces inside `internal/api/` are deliberately outside the diff --git a/engdocs/contributors/huma-usage.md b/engdocs/contributors/huma-usage.md index b8ab77058f..3432123812 100644 --- a/engdocs/contributors/huma-usage.md +++ b/engdocs/contributors/huma-usage.md @@ -325,6 +325,34 @@ real path. import "github.com/danielgtaylor/huma/v2/adapters/humago" ``` +## 12. The `huma.NewError` override and `Operation.Errors` + +`internal/api/errors_install.go` replaces the `huma.NewError` package var at +package-init so every error the API produces is an `*apierr.ErrorModel` carrying +a machine `type` URN + `code` (see `internal/api/apierr` and the control-plane +doc §3.8). Two gotchas fall out of this: + +- **The override changes the runtime type of every Huma error, but not the + wire.** Overriding `NewError` covers `NewErrorWithContext` too (Huma's default + delegates to the `NewError` var at call time, and the serving path goes through + it). For everything except Huma's built-in request-validation 422, the override + wraps Huma's own `ErrorModel` verbatim with an empty (omitted) `code`, so the + JSON is byte-identical — locked by round-trip + `TestOpenAPISpecInSync`. Mint a + *typed* error through the `apierr` catalog constructors + (`apierr.BeadNotFound.Msg(...)`), never a raw `&huma.ErrorModel{}` literal or a + bare `urn:gascity:error:` string (the `TestEveryEmittedErrorCodeIsRegistered` + guard forbids the latter outside `apierr/`). + +- **`Operation.Errors` auto-appends 422 and 500.** When you declare any error + status on an operation (directly, or via the `errorStatuses(...)` operation + handler), Huma additionally appends `422` (for ops with path params or a body — + i.e. every city-scoped op) and `500`, then emits one response per status and + **suppresses the `default` response** (`huma.go` `defineErrors`). So an op you + give `errorStatuses(http.StatusNotFound)` shows `404`, `422`, `500` in the + spec. This is expected — do not hand-edit the generated `openapi.json` to remove + them; pass only the 4xx/503 the handler actually returns and let Huma add the + rest. + ## What we don't use from Huma - **`huma.Group`** — we have a single API per supervisor and a diff --git a/internal/api/apierr/apierr.go b/internal/api/apierr/apierr.go new file mode 100644 index 0000000000..4b888e76c7 --- /dev/null +++ b/internal/api/apierr/apierr.go @@ -0,0 +1,104 @@ +// Package apierr is the registry of machine-readable problem types for the Gas +// City HTTP API. Every error the API can return has a stable code registered +// here; the code is surfaced on the RFC 9457 problem+json body as the canonical +// `type` URN (urn:gascity:error:) plus a convenience `code` member, so an +// autonomous consumer branches on a stable identifier instead of parsing the +// human-readable detail prose. +// +// It mirrors the typed-events registry (internal/events.RegisterPayload): a +// central catalog plus a CI guard that fails the build if the API emits a URN +// that is not registered. Registration happens at package-init time via the +// catalog vars, so Registered() is complete before any route is served. +package apierr + +import ( + "fmt" + "regexp" + "sort" + "sync" +) + +// URNPrefix is the namespace for every Gas City error type URN. The canonical +// machine code is the segment that follows it: type == URNPrefix + code. +const URNPrefix = "urn:gascity:error:" + +// codePattern constrains a machine code to lowercase kebab-case so URNs stay +// stable, greppable, and safe as a wire identifier. A subsystem prefix +// (e.g. "sling-") is encouraged where the code is specific to one surface. +var codePattern = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`) + +// ProblemType is a registered error kind: a stable machine code, the default +// HTTP status it maps to, a short static Title (RFC 9457), and an optional Doc +// URI for human documentation. +type ProblemType struct { + Code string + Status int + Title string + Doc string +} + +// URN returns the canonical type URN for this problem, urn:gascity:error:. +func (pt ProblemType) URN() string { return URNPrefix + pt.Code } + +var ( + mu sync.RWMutex + byCode = map[string]ProblemType{} + registry []ProblemType +) + +// Register records a problem type and returns it, so a catalog entry can be a +// package-level var: `var BeadNotFound = apierr.Register(...)`. It panics on a +// malformed code, a missing status/title, or a conflicting duplicate (a +// programming error surfaced at init). Re-registering an identical entry is a +// no-op, which keeps catalog reloads and test re-imports safe. +func Register(pt ProblemType) ProblemType { + if !codePattern.MatchString(pt.Code) { + panic(fmt.Sprintf("apierr: invalid code %q (want lowercase kebab-case)", pt.Code)) + } + if pt.Status < 400 || pt.Status > 599 { + panic(fmt.Sprintf("apierr: code %q has non-4xx/5xx status %d", pt.Code, pt.Status)) + } + if pt.Title == "" { + panic(fmt.Sprintf("apierr: code %q has empty Title", pt.Code)) + } + mu.Lock() + defer mu.Unlock() + if existing, ok := byCode[pt.Code]; ok { + if existing != pt { + panic(fmt.Sprintf("apierr: conflicting re-register of code %q: %+v vs %+v", pt.Code, existing, pt)) + } + return pt + } + byCode[pt.Code] = pt + registry = append(registry, pt) + return pt +} + +// Lookup returns the problem type for a bare machine code. +func Lookup(code string) (ProblemType, bool) { + mu.RLock() + defer mu.RUnlock() + pt, ok := byCode[code] + return pt, ok +} + +// LookupURN returns the problem type for a full type URN +// (urn:gascity:error:), or false if the string is not such a URN or the +// code is unregistered. +func LookupURN(urn string) (ProblemType, bool) { + if len(urn) <= len(URNPrefix) || urn[:len(URNPrefix)] != URNPrefix { + return ProblemType{}, false + } + return Lookup(urn[len(URNPrefix):]) +} + +// Registered returns every registered problem type, sorted by code so callers +// (and the generated spec) get deterministic output. +func Registered() []ProblemType { + mu.RLock() + out := make([]ProblemType, len(registry)) + copy(out, registry) + mu.RUnlock() + sort.Slice(out, func(i, j int) bool { return out[i].Code < out[j].Code }) + return out +} diff --git a/internal/api/apierr/apierr_test.go b/internal/api/apierr/apierr_test.go new file mode 100644 index 0000000000..e00e6f727d --- /dev/null +++ b/internal/api/apierr/apierr_test.go @@ -0,0 +1,164 @@ +package apierr + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/danielgtaylor/huma/v2" +) + +// ErrorModel must satisfy huma.StatusError so it can be returned from handlers +// and thrown by the huma.NewError override. +var _ huma.StatusError = (*ErrorModel)(nil) + +func TestRegister_RejectsMalformedCode(t *testing.T) { + for _, bad := range []string{"", "Bad", "has_underscore", "-leading", "trailing-", "double--dash", "UPPER"} { + t.Run(bad, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatalf("Register(%q) should panic on malformed code", bad) + } + }() + Register(ProblemType{Code: bad, Status: 400, Title: "x"}) + }) + } +} + +func TestRegister_RejectsBadStatusOrTitle(t *testing.T) { + cases := []ProblemType{ + {Code: "code-a", Status: 200, Title: "ok"}, // non-4xx/5xx + {Code: "code-b", Status: 400, Title: ""}, // empty title + } + for _, pt := range cases { + func() { + defer func() { + if recover() == nil { + t.Fatalf("Register(%+v) should panic", pt) + } + }() + Register(pt) + }() + } +} + +func TestRegister_IdempotentVsConflict(t *testing.T) { + pt := ProblemType{Code: "dup-test-code", Status: 409, Title: "Dup"} + Register(pt) + Register(pt) // identical re-register: no-op, must not panic + + defer func() { + if recover() == nil { + t.Fatal("conflicting re-register must panic") + } + }() + Register(ProblemType{Code: "dup-test-code", Status: 400, Title: "Different"}) +} + +func TestLookupAndURN(t *testing.T) { + pt, ok := Lookup("bead-not-found") + if !ok || pt.Status != http.StatusNotFound { + t.Fatalf("Lookup(bead-not-found) = %+v,%v", pt, ok) + } + if pt.URN() != "urn:gascity:error:bead-not-found" { + t.Fatalf("URN = %q", pt.URN()) + } + got, ok := LookupURN("urn:gascity:error:bead-not-found") + if !ok || got != pt { + t.Fatalf("LookupURN = %+v,%v", got, ok) + } + if _, ok := LookupURN("urn:gascity:error:nope"); ok { + t.Fatal("LookupURN of unregistered code should be false") + } + if _, ok := LookupURN("bead-not-found"); ok { + t.Fatal("LookupURN of a bare code (no prefix) should be false") + } +} + +// The three original sling URNs must stay byte-identical — they are already +// public in the OpenAPI spec via x-gascity-problem-types. +func TestFrozenSlingURNs(t *testing.T) { + for _, want := range []string{ + "urn:gascity:error:sling-missing-bead", + "urn:gascity:error:sling-cross-rig", + "urn:gascity:error:sling-cross-store-route", + } { + if _, ok := LookupURN(want); !ok { + t.Fatalf("frozen URN %q missing from the registry", want) + } + } +} + +func TestRegisteredIsSorted(t *testing.T) { + reg := Registered() + for i := 1; i < len(reg); i++ { + if reg[i-1].Code >= reg[i].Code { + t.Fatalf("Registered() not sorted by code at %d: %q >= %q", i, reg[i-1].Code, reg[i].Code) + } + } +} + +func TestConstructorsStampTypeCodeStatusTitle(t *testing.T) { + e := BeadNotFound.Msg("bead bd-1 not found") + if e.Type != "urn:gascity:error:bead-not-found" || e.Code != "bead-not-found" { + t.Fatalf("Msg type/code = %q/%q", e.Type, e.Code) + } + if e.Status != http.StatusNotFound || e.Title != "Bead Not Found" || e.Detail != "bead bd-1 not found" { + t.Fatalf("Msg status/title/detail = %d/%q/%q", e.Status, e.Title, e.Detail) + } + if e.GetStatus() != http.StatusNotFound { + t.Fatalf("GetStatus = %d (StatusError not satisfied via embedding)", e.GetStatus()) + } + + if got := InvalidRequest.Msgf("field %q required", "name").Detail; got != `field "name" required` { + t.Fatalf("Msgf detail = %q", got) + } + + withList := ConflictWrongState.With("conflict", &huma.ErrorDetail{Message: "d1"}) + if len(withList.Errors) != 1 || withList.Errors[0].Message != "d1" { + t.Fatalf("With errors = %+v", withList.Errors) + } + + ws := StoreUnavailable.WithStatus(http.StatusInternalServerError, "boom") + if ws.Status != http.StatusInternalServerError || ws.Code != "store-unavailable" { + t.Fatalf("WithStatus status/code = %d/%q", ws.Status, ws.Code) + } +} + +// The wire shape must flatten the embedded huma.ErrorModel and add `code`. +func TestErrorModelJSONShape(t *testing.T) { + b, err := json.Marshal(BeadNotFound.Msg("nope")) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for k, want := range map[string]any{ + "type": "urn:gascity:error:bead-not-found", + "code": "bead-not-found", + "title": "Bead Not Found", + "detail": "nope", + "status": float64(http.StatusNotFound), + } { + if m[k] != want { + t.Fatalf("json[%q] = %v (%T), want %v", k, m[k], m[k], want) + } + } + // code is omitempty: an empty-code model omits it (defends the wire compat + // claim for legacy paths that don't stamp a code). + b2, _ := json.Marshal(&ErrorModel{}) + if json.Valid(b2) && contains(string(b2), `"code"`) { + t.Fatalf("empty ErrorModel must omit code, got %s", b2) + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/internal/api/apierr/catalog.go b/internal/api/apierr/catalog.go new file mode 100644 index 0000000000..93ef942d92 --- /dev/null +++ b/internal/api/apierr/catalog.go @@ -0,0 +1,97 @@ +package apierr + +import "net/http" + +// The catalog: every machine-readable problem type the API emits, registered at +// package init. Keep this the single reviewable taxonomy file. Codes are +// generic by default (bead-not-found, not bead-N-not-found) and refined only +// where a client must branch differently (two distinct 409 conflicts). Adding a +// code is additive; removing or renaming one is a breaking change. +// +// The three sling-* URNs are frozen: they are already public in the OpenAPI spec +// via x-gascity-problem-types and must stay byte-identical. +var ( + // Resource resolution. Codes are per-resource (city-not-found, not a generic + // not-found) so a client branches on which resource was missing; rig-not-found + // is shared across the domains that resolve a rig. + CityNotFound = Register(ProblemType{Code: "city-not-found", Status: http.StatusNotFound, Title: "City Not Found"}) + BeadNotFound = Register(ProblemType{Code: "bead-not-found", Status: http.StatusNotFound, Title: "Bead Not Found"}) + MailNotFound = Register(ProblemType{Code: "mail-not-found", Status: http.StatusNotFound, Title: "Mail Message Not Found"}) + RigNotFound = Register(ProblemType{Code: "rig-not-found", Status: http.StatusNotFound, Title: "Rig Not Found"}) + SessionNotFound = Register(ProblemType{Code: "session-not-found", Status: http.StatusNotFound, Title: "Session Not Found"}) + AgentNotFound = Register(ProblemType{Code: "agent-not-found", Status: http.StatusNotFound, Title: "Agent Not Found"}) + ProviderNotFound = Register(ProblemType{Code: "provider-not-found", Status: http.StatusNotFound, Title: "Provider Not Found"}) + ConvoyNotFound = Register(ProblemType{Code: "convoy-not-found", Status: http.StatusNotFound, Title: "Convoy Not Found"}) + WorkflowNotFound = Register(ProblemType{Code: "workflow-not-found", Status: http.StatusNotFound, Title: "Workflow Not Found"}) + FormulaNotFound = Register(ProblemType{Code: "formula-not-found", Status: http.StatusNotFound, Title: "Formula Not Found"}) + OrderNotFound = Register(ProblemType{Code: "order-not-found", Status: http.StatusNotFound, Title: "Order Not Found"}) + ExtmsgGroupNotFound = Register(ProblemType{Code: "extmsg-group-not-found", Status: http.StatusNotFound, Title: "External-Message Group Not Found"}) + // ScopeNotFound is a city-or-rig scope reference that does not resolve (the + // detail names which kind); it is distinct from the resource the scope was + // being resolved for (e.g. a formula). + ScopeNotFound = Register(ProblemType{Code: "scope-not-found", Status: http.StatusNotFound, Title: "Scope Not Found"}) + ServiceNotFound = Register(ProblemType{Code: "service-not-found", Status: http.StatusNotFound, Title: "Service Not Found"}) + PatchNotFound = Register(ProblemType{Code: "patch-not-found", Status: http.StatusNotFound, Title: "Patch Not Found"}) + PackNotFound = Register(ProblemType{Code: "pack-not-found", Status: http.StatusNotFound, Title: "Pack Not Found"}) + + // Request validation. + InvalidRequest = Register(ProblemType{Code: "invalid-request", Status: http.StatusBadRequest, Title: "Invalid Request"}) + ValidationFailed = Register(ProblemType{Code: "validation-failed", Status: http.StatusUnprocessableEntity, Title: "Validation Failed"}) + // WebhookRejected is a well-formed webhook request the receiver declined to + // dispatch (unknown/unwired sink, policy) — distinct from validation-failed, + // which is huma's schema-validation auto-stamp. + WebhookRejected = Register(ProblemType{Code: "webhook-rejected", Status: http.StatusUnprocessableEntity, Title: "Webhook Rejected"}) + + // Concurrency / state conflicts. concurrent-delete/concurrent-modify are + // retryable lost-update races (the target changed under the write); wrong-state + // is a terminal precondition failure (the target is in a state the request + // cannot proceed from). + ConflictConcurrentDelete = Register(ProblemType{Code: "conflict-concurrent-delete", Status: http.StatusConflict, Title: "Concurrent Delete Conflict"}) + ConflictConcurrentModify = Register(ProblemType{Code: "conflict-concurrent-modify", Status: http.StatusConflict, Title: "Concurrent Modify Conflict"}) + ConflictWrongState = Register(ProblemType{Code: "conflict-wrong-state", Status: http.StatusConflict, Title: "Wrong State Conflict"}) + // SessionConflict is the one code for the session 409s. Many carry a + // differentiating detail prefix the CLI already branches on + // (ambiguous:/pending_interaction:/no_pending:/invalid_interaction:/ + // illegal_transition:), mirroring sling-source-workflow-conflict; the rest + // share a generic "conflict:" (or no) prefix. A later slice may split the + // create-time name/alias-uniqueness conflicts into their own code, since a + // client cannot today distinguish "pick a different name" from "resume/stop + // first" by code or prefix. + SessionConflict = Register(ProblemType{Code: "session-conflict", Status: http.StatusConflict, Title: "Session State Conflict"}) + + // AmbiguousReference is a name/reference that matched more than one resource; + // the client should re-address with a scoped/qualified name, not retry or wait. + AmbiguousReference = Register(ProblemType{Code: "ambiguous-reference", Status: http.StatusConflict, Title: "Ambiguous Reference"}) + // OperationInProgress is a transient 409 — another operation on the same target + // is running; the client may retry — distinct from a terminal "already exists" + // wrong-state conflict. + OperationInProgress = Register(ProblemType{Code: "operation-in-progress", Status: http.StatusConflict, Title: "Operation In Progress"}) + + // Authorization / capability. + Forbidden = Register(ProblemType{Code: "forbidden", Status: http.StatusForbidden, Title: "Forbidden"}) + NotImplemented = Register(ProblemType{Code: "not-implemented", Status: http.StatusNotImplemented, Title: "Not Implemented"}) + + // Idempotency (two-phase reserve/complete). + IdempotencyInFlight = Register(ProblemType{Code: "idempotency-in-flight", Status: http.StatusConflict, Title: "Idempotency Key In Flight"}) + IdempotencyMismatch = Register(ProblemType{Code: "idempotency-mismatch", Status: http.StatusUnprocessableEntity, Title: "Idempotency Key Body Mismatch"}) + + // Backend availability. store-unavailable is the bead-store-not-live 503 emitted + // by the shared cacheLiveOr503 helper; service-unavailable is the generic 503 + // that every other converted plain 503 uses — its title matches http.StatusText + // so the wire title is preserved. + StoreUnavailable = Register(ProblemType{Code: "store-unavailable", Status: http.StatusServiceUnavailable, Title: "Store Unavailable"}) + ServiceUnavailable = Register(ProblemType{Code: "service-unavailable", Status: http.StatusServiceUnavailable, Title: "Service Unavailable"}) + Internal = Register(ProblemType{Code: "internal", Status: http.StatusInternalServerError, Title: "Internal Server Error"}) + + // Generic transport statuses. Titles match http.StatusText so converting a + // plain error of these statuses preserves the wire title. + MethodNotAllowed = Register(ProblemType{Code: "method-not-allowed", Status: http.StatusMethodNotAllowed, Title: "Method Not Allowed"}) + BadGateway = Register(ProblemType{Code: "bad-gateway", Status: http.StatusBadGateway, Title: "Bad Gateway"}) + GatewayTimeout = Register(ProblemType{Code: "gateway-timeout", Status: http.StatusGatewayTimeout, Title: "Gateway Timeout"}) + + // Sling. The first three are frozen (already public in the spec). + SlingMissingBead = Register(ProblemType{Code: "sling-missing-bead", Status: http.StatusBadRequest, Title: "Sling Missing Bead"}) + SlingCrossRig = Register(ProblemType{Code: "sling-cross-rig", Status: http.StatusBadRequest, Title: "Sling Cross-Rig"}) + SlingCrossStoreRoute = Register(ProblemType{Code: "sling-cross-store-route", Status: http.StatusBadRequest, Title: "Sling Cross-Store Route"}) + SlingSourceWorkflowConflict = Register(ProblemType{Code: "sling-source-workflow-conflict", Status: http.StatusConflict, Title: "Sling Source Workflow Conflict"}) +) diff --git a/internal/api/apierr/model.go b/internal/api/apierr/model.go new file mode 100644 index 0000000000..ba25d04b88 --- /dev/null +++ b/internal/api/apierr/model.go @@ -0,0 +1,59 @@ +package apierr + +import ( + "fmt" + + "github.com/danielgtaylor/huma/v2" +) + +// ErrorModel is the Gas City problem+json body. It embeds huma.ErrorModel (so it +// inherits the RFC 9457 shape — type/title/status/detail/instance/errors — plus +// the StatusError behavior and the application/problem+json content type) and +// adds a first-class machine-readable `code`. The Go type is named ErrorModel so +// Huma's DefaultSchemaNamer keeps the OpenAPI schema name "ErrorModel"; `code` is +// an additive, omitempty member, so the wire shape stays backward compatible. +// +// The canonical machine identifier is the `type` URN (urn:gascity:error:); +// `code` is a convenience projection of the URN's final segment for consumers +// that switch on short slugs. The registry entry is the single source of truth. +type ErrorModel struct { + huma.ErrorModel + Code string `json:"code,omitempty" doc:"Stable machine-readable error code (the final segment of the type URN)."` +} + +// new builds an ErrorModel stamped with this problem type's URN, code, title, and +// status, at the given occurrence-specific detail and status. +func (pt ProblemType) new(status int, detail string, details []*huma.ErrorDetail) *ErrorModel { + return &ErrorModel{ + ErrorModel: huma.ErrorModel{ + Type: pt.URN(), + Title: pt.Title, + Status: status, + Detail: detail, + Errors: details, + }, + Code: pt.Code, + } +} + +// Msg builds an error of this problem type at its default status with a +// human-readable detail. This is the primary constructor — the one way to mint a +// gascity API error so the registered code/URN is always stamped. +func (pt ProblemType) Msg(detail string) *ErrorModel { return pt.new(pt.Status, detail, nil) } + +// Msgf is Msg with a printf-style detail. +func (pt ProblemType) Msgf(format string, a ...any) *ErrorModel { + return pt.new(pt.Status, fmt.Sprintf(format, a...), nil) +} + +// With builds an error carrying an errors[] list of individual detail entries +// (RFC 9457 "errors" member) in addition to the top-level detail. +func (pt ProblemType) With(detail string, details ...*huma.ErrorDetail) *ErrorModel { + return pt.new(pt.Status, detail, details) +} + +// WithStatus overrides the default status for the rare case where one problem +// type maps to more than one status. The code/URN/title are unchanged. +func (pt ProblemType) WithStatus(status int, detail string) *ErrorModel { + return pt.new(status, detail, nil) +} diff --git a/internal/api/apierr/testenv_import_test.go b/internal/api/apierr/testenv_import_test.go new file mode 100644 index 0000000000..35a2b1b0a7 --- /dev/null +++ b/internal/api/apierr/testenv_import_test.go @@ -0,0 +1,5 @@ +// Code generated by go run scripts/add-testenv-import.go; DO NOT EDIT. + +package apierr + +import _ "github.com/gastownhall/gascity/internal/testenv" diff --git a/internal/api/apierr_guard_test.go b/internal/api/apierr_guard_test.go new file mode 100644 index 0000000000..524492e985 --- /dev/null +++ b/internal/api/apierr_guard_test.go @@ -0,0 +1,157 @@ +package api + +import ( + "encoding/json" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "regexp" + "runtime" + "strings" + "testing" + "time" + + "github.com/gastownhall/gascity/internal/api/apierr" +) + +// urnLiteralRe matches any Gas City error-type URN literal as it would appear in +// source — the prefix plus whatever follows up to the closing string delimiter +// (quote, whitespace, or backtick). It intentionally does NOT constrain the tail +// to kebab-case: a malformed or mis-cased code (e.g. "...:Rogue", "...:2fa") can +// never be registered, so requiring the tail to look well-formed to be seen would +// make the guard silently ignore exactly the typos it exists to catch. A bare +// prefix (empty tail) matches too and fails LookupURN, so a literal +// "urn:gascity:error:" concatenated with a code is caught as well. +var urnLiteralRe = regexp.MustCompile("urn:gascity:error:[^\"\\s`]*") + +// guardSkipDirs are directory names pruned from the source walk: VCS/build/vendor +// noise plus nested worktree state, none of which is shipped Gas City Go. +var guardSkipDirs = map[string]bool{ + ".git": true, ".claude": true, "node_modules": true, "vendor": true, "testdata": true, +} + +// TestEveryEmittedErrorCodeIsRegistered is the error-contract analog of +// TestEveryKnownEventTypeHasRegisteredPayload: it guarantees the API cannot ship +// a problem-type URN the registry doesn't know about. Every urn:gascity:error: +// string literal in non-test Go anywhere in the module (internal/, cmd/, pkg/, +// root, …) must resolve via apierr.LookupURN, and the apierr package is the sole +// place allowed to author a URN literal — every other site must mint errors +// through the catalog constructors (which derive the URN from the registry) so +// the type can never drift from a registered code. Mirrors the source-walk in +// cmd/gc/worker_boundary_import_test.go. +func TestEveryEmittedErrorCodeIsRegistered(t *testing.T) { + _, currentFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + repoRoot := filepath.Join(filepath.Dir(currentFile), "..", "..") + + // Walk the whole module, not just internal/+cmd/, so a raw literal cannot hide + // in pkg/, a module-root file, scripts/, or examples/. + err := filepath.WalkDir(repoRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if guardSkipDirs[d.Name()] { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + // The apierr package is the registry itself: it authors the URN prefix and + // (in its own docs) sample URNs. It is the one sanctioned definer. Match the + // exact package path so an unrelated ".../api/apierr/..." directory elsewhere + // is not accidentally exempted. + if strings.Contains(filepath.ToSlash(path), "/internal/api/apierr/") { + return nil + } + data, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + for _, urn := range urnLiteralRe.FindAllString(string(data), -1) { + if _, ok := apierr.LookupURN(urn); !ok { + t.Errorf("%s contains unregistered error URN %q — register it in internal/api/apierr/catalog.go or mint it through the catalog constructors", path, urn) + } else { + t.Errorf("%s authors a raw error URN literal %q — mint the error through the apierr catalog constructor instead so the URN derives from the registry", path, urn) + } + } + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", repoRoot, err) + } +} + +// TestErrorModelSpecProjection locks the two spec artifacts documentProblemTypes +// produces from the registry: the ErrorModel schema carries the machine `code` +// property, and the x-gascity-problem-types extension is exactly the sorted set +// of registered URNs. This is what keeps the published contract in lockstep with +// the catalog. +func TestErrorModelSpecProjection(t *testing.T) { + sm := NewSupervisorMux(emptyRoundtripResolver{}, nil, false, "", "", time.Time{}) + req := httptest.NewRequest(http.MethodGet, "/openapi.json", nil) + rec := httptest.NewRecorder() + sm.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET /openapi.json = %d: %s", rec.Code, rec.Body.String()) + } + + var spec struct { + Components struct { + Schemas map[string]struct { + Properties map[string]struct { + Extensions map[string]json.RawMessage `json:"-"` + Examples []any `json:"examples"` + } `json:"properties"` + } `json:"schemas"` + } `json:"components"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &spec); err != nil { + t.Fatalf("parse spec: %v", err) + } + + errorModel, ok := spec.Components.Schemas["ErrorModel"] + if !ok { + t.Fatal("spec is missing the ErrorModel schema") + } + if _, ok := errorModel.Properties["code"]; !ok { + t.Fatal("ErrorModel schema is missing the machine `code` property") + } + + // x-gascity-problem-types must equal the sorted registry URNs. Re-parse the + // raw type-property object to read the extension (Huma inlines x- extensions + // as sibling keys on the schema object). + var rawSpec struct { + Components struct { + Schemas map[string]struct { + Properties map[string]map[string]any `json:"properties"` + } `json:"schemas"` + } `json:"components"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &rawSpec); err != nil { + t.Fatalf("parse spec (raw): %v", err) + } + typeProp := rawSpec.Components.Schemas["ErrorModel"].Properties["type"] + got, _ := typeProp["x-gascity-problem-types"].([]any) + var gotURNs []string + for _, v := range got { + if s, ok := v.(string); ok { + gotURNs = append(gotURNs, s) + } + } + + var wantURNs []string + for _, pt := range apierr.Registered() { + wantURNs = append(wantURNs, pt.URN()) + } + if !reflect.DeepEqual(gotURNs, wantURNs) { + t.Fatalf("x-gascity-problem-types mismatch:\n got=%v\nwant=%v", gotURNs, wantURNs) + } +} diff --git a/internal/api/apierr_roundtrip_test.go b/internal/api/apierr_roundtrip_test.go new file mode 100644 index 0000000000..5a8c322f5a --- /dev/null +++ b/internal/api/apierr_roundtrip_test.go @@ -0,0 +1,150 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" +) + +// The huma.NewError override is the load-bearing seam for the error contract: +// it must (a) stamp huma's built-in request-validation 422 with the +// validation-failed problem type and (b) leave every other error byte-identical +// on the wire so unconverted call sites keep their exact shape. These tests lock +// both halves. + +// TestNewErrorOverride_ValidationFailedStamped verifies huma's internal 422 +// ("validation failed") is re-typed as the validation-failed problem type. +func TestNewErrorOverride_ValidationFailedStamped(t *testing.T) { + err := huma.NewError(http.StatusUnprocessableEntity, "validation failed", + &huma.ErrorDetail{Message: "expected required property title to be present", Location: "body.title"}) + em, ok := err.(*apierr.ErrorModel) + if !ok { + t.Fatalf("override must return *apierr.ErrorModel, got %T", err) + } + if em.Type != "urn:gascity:error:validation-failed" || em.Code != "validation-failed" { + t.Fatalf("validation 422 type/code = %q/%q, want validation-failed", em.Type, em.Code) + } + if em.Title != "Validation Failed" { + t.Fatalf("validation 422 title = %q, want %q", em.Title, "Validation Failed") + } + if em.Status != http.StatusUnprocessableEntity || em.Detail != "validation failed" { + t.Fatalf("validation 422 status/detail = %d/%q", em.Status, em.Detail) + } + if len(em.Errors) != 1 || em.Errors[0].Location != "body.title" { + t.Fatalf("validation 422 must preserve huma's field errors, got %+v", em.Errors) + } +} + +// TestNewErrorOverride_LegacyIsByteIdentical verifies every non-validation error +// is wrapped as *apierr.ErrorModel with an empty (omitted) code, marshaling +// byte-for-byte the same as huma's default ErrorModel. This is the wire-compat +// guarantee for the ~376 unconverted call sites. +func TestNewErrorOverride_LegacyIsByteIdentical(t *testing.T) { + cases := []struct { + status int + msg string + }{ + {http.StatusInternalServerError, "boom"}, + {http.StatusServiceUnavailable, "no bead store configured"}, + {http.StatusNotFound, "bead bd-9 not found"}, + {http.StatusConflict, "conflict: bead bd-9 was deleted concurrently"}, + {http.StatusBadRequest, "rig is required when multiple rigs are configured"}, + // A hand-written 422 whose message is NOT huma's marker must stay legacy. + {http.StatusUnprocessableEntity, "at least one of 'title' or 'alias' is required"}, + } + for _, tc := range cases { + t.Run(http.StatusText(tc.status)+"/"+tc.msg, func(t *testing.T) { + got := huma.NewError(tc.status, tc.msg) + if _, ok := got.(*apierr.ErrorModel); !ok { + t.Fatalf("override must return *apierr.ErrorModel, got %T", got) + } + gotJSON, err := json.Marshal(got) + if err != nil { + t.Fatalf("marshal override: %v", err) + } + if strings.Contains(string(gotJSON), `"code"`) { + t.Fatalf("legacy error must omit code, got %s", gotJSON) + } + // huma's exact default construction for this (status, msg, no errs). + want, err := json.Marshal(&huma.ErrorModel{ + Status: tc.status, + Title: http.StatusText(tc.status), + Detail: tc.msg, + }) + if err != nil { + t.Fatalf("marshal want: %v", err) + } + if string(gotJSON) != string(want) { + t.Fatalf("legacy wire not byte-identical:\n got=%s\nwant=%s", gotJSON, want) + } + }) + } +} + +// TestNewErrorOverride_EndToEndValidation drives a real request through the +// supervisor mux so the override is exercised on the actual serving path +// (through NewErrorWithContext, which delegates to the NewError var). A negative +// limit fails huma's built-in query validation with a 422. +func TestNewErrorOverride_EndToEndValidation(t *testing.T) { + sm := NewSupervisorMux(emptyRoundtripResolver{}, nil, false, "", "", time.Time{}) + req := httptest.NewRequest(http.MethodGet, "/v0/city/anycity/beads?limit=-1", nil) + rec := httptest.NewRecorder() + sm.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("GET beads?limit=-1 returned %d, want 422: %s", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/problem+json") { + t.Fatalf("content-type = %q, want application/problem+json", ct) + } + var body apierr.ErrorModel + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal problem body: %v (%s)", err, rec.Body.String()) + } + if body.Type != "urn:gascity:error:validation-failed" || body.Code != "validation-failed" { + t.Fatalf("end-to-end validation type/code = %q/%q, want validation-failed", body.Type, body.Code) + } +} + +// TestNewErrorOverride_ValidationFailedAtNon422Status locks the fix for the case +// where Huma emits its "validation failed" marker at a status other than 422 — a +// 400 for a body it cannot parse. The override must stamp validation-failed there +// too (preserving the 400), or a client branching on type/code would mis-classify +// every malformed-body request. +func TestNewErrorOverride_ValidationFailedAtNon422Status(t *testing.T) { + sm := NewSupervisorMux(emptyRoundtripResolver{}, nil, false, "", "", time.Time{}) + // A truncated JSON body fails Huma's body parse with status 400, detail + // "validation failed". POST needs the anti-CSRF header to reach validation. + req := httptest.NewRequest(http.MethodPost, "/v0/city/anycity/beads", strings.NewReader(`{"title":`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-GC-Request", "1") + rec := httptest.NewRecorder() + sm.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("malformed body returned %d, want 400: %s", rec.Code, rec.Body.String()) + } + var body apierr.ErrorModel + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal problem body: %v (%s)", err, rec.Body.String()) + } + if body.Type != "urn:gascity:error:validation-failed" || body.Code != "validation-failed" { + t.Fatalf("400 validation type/code = %q/%q, want validation-failed", body.Type, body.Code) + } + if body.Status != http.StatusBadRequest { + t.Fatalf("validation-failed body Status = %d, want 400 (Huma's status must be preserved)", body.Status) + } +} + +// emptyRoundtripResolver is a CityResolver with no cities; huma validation runs +// before city resolution, so a validation 422 never needs a live city. +type emptyRoundtripResolver struct{} + +func (emptyRoundtripResolver) ListCities() []CityInfo { return nil } +func (emptyRoundtripResolver) CityState(_ string) State { return nil } diff --git a/internal/api/cache_liveness.go b/internal/api/cache_liveness.go index 0a96fe10a8..bbb49e9ccd 100644 --- a/internal/api/cache_liveness.go +++ b/internal/api/cache_liveness.go @@ -3,7 +3,7 @@ package api import ( "time" - "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/beads" ) @@ -32,7 +32,7 @@ func cacheLiveOr503(store beads.Store) error { if lr.IsLive() { return nil } - return huma.Error503ServiceUnavailable("cache_not_live: supervisor cache is priming or reconciling; retry via fallback") + return apierr.StoreUnavailable.Msg("cache_not_live: supervisor cache is priming or reconciling; retry via fallback") } // cacheAgeSeconds returns the age in seconds of the store's latest fresh diff --git a/internal/api/city_scope.go b/internal/api/city_scope.go index 03dbc95683..d7b93b35b2 100644 --- a/internal/api/city_scope.go +++ b/internal/api/city_scope.go @@ -7,6 +7,8 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/sse" + + "github.com/gastownhall/gascity/internal/api/apierr" ) // CityScope is the path-parameter mixin embedded by every city-scoped @@ -72,7 +74,7 @@ func bindCity[I any, O any]( name := named.GetCityName() srv := sm.resolveCityServer(name) if srv == nil { - return nil, huma.Error404NotFound(CityNotFoundOrNotRunningDetail(name)) + return nil, apierr.CityNotFound.Msg(CityNotFoundOrNotRunningDetail(name)) } return fn(srv, ctx, input) } @@ -121,13 +123,29 @@ func addMutationCSRFParam(op *huma.Operation) { }) } +// errorStatuses returns an operation handler that declares the given HTTP status +// codes as possible error responses on the operation. Huma then documents one +// problem+json response per status (schema ErrorModel) and — because +// Operation.Errors is non-empty — additionally appends the auto 422 (for ops +// with path params or a body) and 500. Passing the 4xx/503 an op can emit turns +// its catch-all `default` error response into an enumerated, machine-branchable +// contract. Pass only the statuses the handler actually returns; do not pass 422 +// or 500 (Huma adds those). +func errorStatuses(codes ...int) func(o *huma.Operation) { + return func(o *huma.Operation) { + o.Errors = append(o.Errors, codes...) + } +} + // cityGet registers a per-city GET op at /v0/city/{cityName}+tail. // The tail starts with "/" (e.g. "/agents") or is "" for the -// city-detail base path. +// city-detail base path. Optional opts (e.g. errorStatuses) customize the +// generated operation. func cityGet[I any, O any](sm *SupervisorMux, tail string, fn func(*Server, context.Context, *I) (*O, error), + opts ...func(o *huma.Operation), ) { - huma.Get(sm.humaAPI, cityScopePrefix+tail, bindCity(sm, fn)) + huma.Get(sm.humaAPI, cityScopePrefix+tail, bindCity(sm, fn), opts...) } // cityPost is the POST sibling of cityGet. Every city-scoped POST @@ -156,16 +174,20 @@ func cityPut[I any, O any](sm *SupervisorMux, tail string, // header rationale. func cityPatch[I any, O any](sm *SupervisorMux, tail string, fn func(*Server, context.Context, *I) (*O, error), + opts ...func(o *huma.Operation), ) { - huma.Patch(sm.humaAPI, cityScopePrefix+tail, bindCity(sm, fn), addMutationCSRFParam) + huma.Patch(sm.humaAPI, cityScopePrefix+tail, bindCity(sm, fn), + append([]func(o *huma.Operation){addMutationCSRFParam}, opts...)...) } // cityDelete is the DELETE sibling of cityGet. See cityPost for the // CSRF header rationale. func cityDelete[I any, O any](sm *SupervisorMux, tail string, fn func(*Server, context.Context, *I) (*O, error), + opts ...func(o *huma.Operation), ) { - huma.Delete(sm.humaAPI, cityScopePrefix+tail, bindCity(sm, fn), addMutationCSRFParam) + huma.Delete(sm.humaAPI, cityScopePrefix+tail, bindCity(sm, fn), + append([]func(o *huma.Operation){addMutationCSRFParam}, opts...)...) } // cityRegister is the per-city analog of huma.Register. Use it when @@ -193,7 +215,7 @@ func sseCityPrecheck[I any](sm *SupervisorMux, name := cityScopeName(input) srv := sm.resolveCityServer(name) if srv == nil { - return huma.Error404NotFound(CityNotFoundOrNotRunningDetail(name)) + return apierr.CityNotFound.Msg(CityNotFoundOrNotRunningDetail(name)) } return fn(srv, ctx, input) } diff --git a/internal/api/client.go b/internal/api/client.go index 6081ed78f9..b6afcfb51d 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -485,7 +485,7 @@ func (c *Client) ListCities() ([]CityInfo, error) { if resp == nil { return nil, &connError{err: fmt.Errorf("nil response")} } - if err := apiErrorFromResponse(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := apiErrorFromResponse(resp.StatusCode(), pdOf(resp)); err != nil { return nil, err } if resp.JSON200 == nil || resp.JSON200.Items == nil { @@ -511,7 +511,7 @@ func (c *Client) ListServices() ([]workspacesvc.Status, error) { if resp == nil { return nil, &connError{err: fmt.Errorf("nil response")} } - if err := apiErrorFromResponse(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := apiErrorFromResponse(resp.StatusCode(), pdOf(resp)); err != nil { return nil, err } if resp.JSON200 == nil || resp.JSON200.Items == nil { @@ -552,7 +552,7 @@ func (c *Client) GetOrderHistory(scopedName string, limit int, before string) (C if resp == nil { return CachedRead[[]OrderHistoryView]{}, &connError{err: fmt.Errorf("nil response")} } - if err := apiErrorFromResponse(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := apiErrorFromResponse(resp.StatusCode(), pdOf(resp)); err != nil { return CachedRead[[]OrderHistoryView]{}, err } return CachedRead[[]OrderHistoryView]{ @@ -578,7 +578,7 @@ func (c *Client) GetMaintenanceStatus() (CachedRead[MaintenanceStatusView], erro if resp == nil { return CachedRead[MaintenanceStatusView]{}, &connError{err: fmt.Errorf("nil response")} } - if err := apiErrorFromResponse(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := apiErrorFromResponse(resp.StatusCode(), pdOf(resp)); err != nil { return CachedRead[MaintenanceStatusView]{}, err } return CachedRead[MaintenanceStatusView]{ @@ -608,7 +608,7 @@ func (c *Client) TriggerMaintenanceDoltGC(wait bool) (MaintenanceTriggerView, er if resp == nil { return MaintenanceTriggerView{}, &connError{err: fmt.Errorf("nil response")} } - if err := apiErrorFromResponse(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := apiErrorFromResponse(resp.StatusCode(), pdOf(resp)); err != nil { return MaintenanceTriggerView{}, err } return maintenanceTriggerViewFromGen(resp.JSON202), nil @@ -642,7 +642,7 @@ func (c *Client) ListSessions(stateFilter, templateFilter string, peek bool) (Ca if resp == nil { return CachedRead[[]SessionView]{}, &connError{err: fmt.Errorf("nil response")} } - if err := apiErrorFromResponse(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := apiErrorFromResponse(resp.StatusCode(), pdOf(resp)); err != nil { return CachedRead[[]SessionView]{}, err } return CachedRead[[]SessionView]{ @@ -674,7 +674,7 @@ func (c *Client) GetSession(id string, peek bool, peekLines int) (CachedRead[Ses if resp == nil { return CachedRead[SessionView]{}, &connError{err: fmt.Errorf("nil response")} } - if err := apiErrorFromResponse(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := apiErrorFromResponse(resp.StatusCode(), pdOf(resp)); err != nil { return CachedRead[SessionView]{}, err } if resp.JSON200 == nil { @@ -702,7 +702,7 @@ func (c *Client) ListRigs() (CachedRead[[]RigView], error) { if resp == nil { return CachedRead[[]RigView]{}, &connError{err: fmt.Errorf("nil response")} } - if err := apiErrorFromResponse(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := apiErrorFromResponse(resp.StatusCode(), pdOf(resp)); err != nil { return CachedRead[[]RigView]{}, err } return CachedRead[[]RigView]{ @@ -727,7 +727,7 @@ func (c *Client) ListConvoys() (CachedRead[[]beads.Bead], error) { if resp == nil { return CachedRead[[]beads.Bead]{}, &connError{err: fmt.Errorf("nil response")} } - if err := apiErrorFromResponse(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := apiErrorFromResponse(resp.StatusCode(), pdOf(resp)); err != nil { return CachedRead[[]beads.Bead]{}, err } return CachedRead[[]beads.Bead]{ @@ -752,7 +752,7 @@ func (c *Client) GetConvoy(id string) (CachedRead[ConvoyStatusView], error) { if resp == nil { return CachedRead[ConvoyStatusView]{}, &connError{err: fmt.Errorf("nil response")} } - if err := apiErrorFromResponse(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := apiErrorFromResponse(resp.StatusCode(), pdOf(resp)); err != nil { return CachedRead[ConvoyStatusView]{}, err } if resp.JSON200 == nil { @@ -778,7 +778,7 @@ func (c *Client) CheckConvoy(id string) (CachedRead[ConvoyCheckView], error) { if resp == nil { return CachedRead[ConvoyCheckView]{}, &connError{err: fmt.Errorf("nil response")} } - if err := apiErrorFromResponse(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := apiErrorFromResponse(resp.StatusCode(), pdOf(resp)); err != nil { return CachedRead[ConvoyCheckView]{}, err } if resp.JSON200 == nil { @@ -845,7 +845,7 @@ func (c *Client) ListBeads(opts ListBeadsOpts) (CachedRead[[]beads.Bead], error) if resp == nil { return CachedRead[[]beads.Bead]{}, &connError{err: fmt.Errorf("nil response")} } - if err := apiErrorFromResponse(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := apiErrorFromResponse(resp.StatusCode(), pdOf(resp)); err != nil { return CachedRead[[]beads.Bead]{}, err } return CachedRead[[]beads.Bead]{ @@ -868,7 +868,7 @@ func (c *Client) GetBead(id string) (CachedRead[beads.Bead], error) { if resp == nil { return CachedRead[beads.Bead]{}, &connError{err: fmt.Errorf("nil response")} } - if err := apiErrorFromResponse(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := apiErrorFromResponse(resp.StatusCode(), pdOf(resp)); err != nil { return CachedRead[beads.Bead]{}, err } if resp.JSON200 == nil { @@ -896,7 +896,7 @@ func (c *Client) GetStatus() (CachedRead[StatusView], error) { if resp == nil { return CachedRead[StatusView]{}, &connError{err: fmt.Errorf("nil response")} } - if err := apiErrorFromResponse(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := apiErrorFromResponse(resp.StatusCode(), pdOf(resp)); err != nil { return CachedRead[StatusView]{}, err } return CachedRead[StatusView]{ @@ -932,7 +932,7 @@ func (c *Client) ListMailInbox(agent, rig string) (CachedRead[MailListView], err if resp == nil { return CachedRead[MailListView]{}, &connError{err: fmt.Errorf("nil response")} } - if err := apiErrorFromResponse(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := apiErrorFromResponse(resp.StatusCode(), pdOf(resp)); err != nil { return CachedRead[MailListView]{}, err } return CachedRead[MailListView]{ @@ -959,7 +959,7 @@ func (c *Client) GetMail(id, rig string) (CachedRead[mail.Message], error) { if resp == nil { return CachedRead[mail.Message]{}, &connError{err: fmt.Errorf("nil response")} } - if err := apiErrorFromResponse(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := apiErrorFromResponse(resp.StatusCode(), pdOf(resp)); err != nil { return CachedRead[mail.Message]{}, err } if resp.JSON200 == nil { @@ -992,7 +992,7 @@ func (c *Client) CountMail(agent, rig string) (CachedRead[MailCountView], error) if resp == nil { return CachedRead[MailCountView]{}, &connError{err: fmt.Errorf("nil response")} } - if err := apiErrorFromResponse(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := apiErrorFromResponse(resp.StatusCode(), pdOf(resp)); err != nil { return CachedRead[MailCountView]{}, err } return CachedRead[MailCountView]{ @@ -1013,7 +1013,7 @@ func (c *Client) GetService(name string) (workspacesvc.Status, error) { if resp == nil { return workspacesvc.Status{}, &connError{err: fmt.Errorf("nil response")} } - if err := apiErrorFromResponse(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := apiErrorFromResponse(resp.StatusCode(), pdOf(resp)); err != nil { return workspacesvc.Status{}, err } if resp.JSON200 == nil { @@ -1093,7 +1093,9 @@ func (c *Client) postRigAction(name, action string) error { if err := c.requireCityScope(); err != nil { return err } - resp, err := c.cw.PostV0CityByCityNameRigByNameByActionWithResponse(context.Background(), c.cityName, name, action, nil) + resp, err := c.cw.PostV0CityByCityNameRigByNameByActionWithResponse( + context.Background(), c.cityName, name, + genclient.PostV0CityByCityNameRigByNameByActionParamsAction(action), nil) return checkMutation(resp, err) } @@ -1162,7 +1164,7 @@ func (c *Client) SubmitSession(id, message string, intent session.SubmitIntent) if resp == nil { return SessionSubmitResponse{}, &connError{err: fmt.Errorf("nil response")} } - if err := apiErrorFromResponse(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := apiErrorFromResponse(resp.StatusCode(), pdOf(resp)); err != nil { return SessionSubmitResponse{}, err } if resp.JSON202 == nil { @@ -1223,16 +1225,18 @@ func isNil(v any) bool { } // pdOf extracts the generated client's decoded Problem Details pointer -// from any generated *WithResponse type. Every response wrapper has an -// `ApplicationproblemJSONDefault *ErrorModel` field produced by -// oapi-codegen from the spec's default `application/problem+json` -// response. Returns nil when the field is absent (no operation without -// the default response has been observed; the nil-safe return is -// defensive) or unpopulated (2xx, non-JSON error). +// from any generated *WithResponse type. An operation that keeps the spec's +// catch-all error decodes it into `ApplicationproblemJSONDefault *ErrorModel`; +// an operation that enumerates its error statuses (the P12 error-contract +// pilot) decodes into `ApplicationproblemJSON *ErrorModel` instead — +// exactly one of which the generator populates, the one matching the HTTP +// status. pdOf returns whichever ErrorModel field is set, so both spec shapes +// are handled uniformly. Returns nil when none is populated (2xx, non-JSON +// error, or an operation with no problem+json error at all). // -// This is spec-driven: the field exists because the spec declares the -// default error to be Problem Details, and the generator decoded it. -// No hand-written JSON parsing happens here or downstream. +// This is spec-driven: the fields exist because the spec declares the error +// responses to be Problem Details, and the generator decoded them. No +// hand-written JSON parsing happens here or downstream. func pdOf(resp any) *genclient.ErrorModel { if resp == nil { return nil @@ -1247,12 +1251,38 @@ func pdOf(resp any) *genclient.ErrorModel { if rv.Kind() != reflect.Struct { return nil } - f := rv.FieldByName("ApplicationproblemJSONDefault") - if !f.IsValid() { - return nil + // Prefer the catch-all field, then fall back to whichever per-status + // ApplicationproblemJSON field the generator populated. + if f := rv.FieldByName("ApplicationproblemJSONDefault"); f.IsValid() { + if pd, _ := f.Interface().(*genclient.ErrorModel); pd != nil { + return pd + } + } + rt := rv.Type() + for i := 0; i < rt.NumField(); i++ { + if !strings.HasPrefix(rt.Field(i).Name, "ApplicationproblemJSON") { + continue + } + if pd, _ := rv.Field(i).Interface().(*genclient.ErrorModel); pd != nil { + return pd + } } - pd, _ := f.Interface().(*genclient.ErrorModel) - return pd + // Fallback: the server returned a status the operation did not enumerate, + // so the generator has no field to decode the problem+json into (e.g. an + // infrastructure or middleware 503 like cache_not_live on a read whose + // declared contract is 404-only). Recover the detail from the raw response + // body so read-path fallback classification still works. Guarded to bodies + // that decode as a Problem Details document so 2xx/non-problem payloads do + // not masquerade as errors. + if bf := rv.FieldByName("Body"); bf.IsValid() { + if body, ok := bf.Interface().([]byte); ok && len(body) > 0 { + var pd genclient.ErrorModel + if json.Unmarshal(body, &pd) == nil && (pd.Detail != nil || pd.Title != nil || pd.Code != nil) { + return &pd + } + } + } + return nil } // apiErrorFromResponse returns nil for 2xx responses, a *readOnlyError @@ -1444,7 +1474,7 @@ func (c *Client) BindExtMsgConversation(spec ExtMsgBindSpec) (extmsg.SessionBind if resp == nil { return extmsg.SessionBindingRecord{}, &connError{err: fmt.Errorf("nil response")} } - if err := apiErrorFromResponse(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := apiErrorFromResponse(resp.StatusCode(), pdOf(resp)); err != nil { return extmsg.SessionBindingRecord{}, err } if resp.JSON200 == nil { @@ -1478,7 +1508,7 @@ func (c *Client) UnbindExtMsgConversation(conversation *extmsg.ConversationRef, if resp == nil { return nil, &connError{err: fmt.Errorf("nil response")} } - if err := apiErrorFromResponse(resp.StatusCode(), resp.ApplicationproblemJSONDefault); err != nil { + if err := apiErrorFromResponse(resp.StatusCode(), pdOf(resp)); err != nil { return nil, err } if resp.JSON200 == nil || resp.JSON200.Unbound == nil { diff --git a/internal/api/client_test.go b/internal/api/client_test.go index e65c18e1d3..7464126bcb 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -561,6 +561,55 @@ func TestClientBusinessErrorNoFallback(t *testing.T) { } } +// TestClientEnumeratedErrorResponseCarriesProblemDetail covers the P12 pilot +// wire shape: bead ops enumerate their error statuses, so oapi-codegen decodes +// the problem body into ApplicationproblemJSON instead of +// ApplicationproblemJSONDefault. pdOf must find the per-status field or the CLI +// would lose the detail and surface a bare status. GetBead (404) and ListBeads +// (503) exercise two different per-status fields. +func TestClientEnumeratedErrorResponseCarriesProblemDetail(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/problem+json") + if r.URL.Path == "/v0/city/alpha/beads" { + w.WriteHeader(http.StatusServiceUnavailable) + json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck + "type": "urn:gascity:error:store-unavailable", + "code": "store-unavailable", + "title": "Store Unavailable", + "status": http.StatusServiceUnavailable, + "detail": "cache_not_live: supervisor cache is priming or reconciling; retry via fallback", + }) + return + } + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck + "type": "urn:gascity:error:bead-not-found", + "code": "bead-not-found", + "title": "Bead Not Found", + "status": http.StatusNotFound, + "detail": "bead bd-x not found", + }) + })) + defer ts.Close() + + c := NewCityScopedClient(ts.URL, "alpha") + + if _, err := c.GetBead("bd-x"); err == nil { + t.Fatal("GetBead: expected error, got nil") + } else if !strings.Contains(err.Error(), "bead bd-x not found") { + t.Fatalf("GetBead error dropped the problem detail (pdOf per-status extraction): %v", err) + } + + // ListBeads returns 503 with a cache-not-live prefix, which the classifier + // turns into a fallbackable error — only reachable if pdOf recovered the + // detail from the per-status field. + if _, err := c.ListBeads(ListBeadsOpts{}); err == nil { + t.Fatal("ListBeads: expected error, got nil") + } else if !ShouldFallback(err) { + t.Fatalf("ListBeads 503 cache-not-live should be fallbackable (pdOf per-status extraction): %v", err) + } +} + func TestClientRestartRig(t *testing.T) { var gotMethod, gotPath string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/errors_install.go b/internal/api/errors_install.go new file mode 100644 index 0000000000..5c331250ed --- /dev/null +++ b/internal/api/errors_install.go @@ -0,0 +1,71 @@ +package api + +import ( + "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" +) + +// validationFailedDetail is the exact detail string Huma uses for its built-in +// request-validation failure. Huma emits WriteErr(..., "validation failed", ...) +// for schema/param/body validation, and the accompanying status is NOT always +// 422: it is 400 for an unparseable body and 415 for an unsupported content type +// (huma.go validateBody), besides the usual 422. We therefore key the stamp on +// this exact marker string at whatever status Huma chose, preserving that status +// — so every built-in validation failure carries the validation-failed type, +// while the many hand-written huma.Error*(...) call sites (distinct messages) +// stay on the legacy path until explicitly converted. +const validationFailedDetail = "validation failed" + +// init replaces huma.NewError so every error the API produces is an +// *apierr.ErrorModel — the RFC 9457 problem+json body with a first-class machine +// `code`. This runs at package-init time, before NewSupervisorMux calls +// huma.Register, so every registered error response and every served error flows +// through it. +// +// Two behaviors: +// +// - Huma's built-in request validation ("validation failed", at 422, or 400 +// for an unparseable body / 415 for an unsupported content type) is stamped +// with the validation-failed problem type (type URN + code + title), while +// preserving Huma's status and the occurrence detail + field-level errors[]. +// This is the one auto-stamped fallback; it gives request validation — the +// most common client-visible error, emitted by Huma itself rather than at +// our call sites — a stable machine identity. +// +// - Every other error is wrapped verbatim: we take Huma's own ErrorModel and +// re-home it inside *apierr.ErrorModel with an empty Code. Because Code is +// omitempty and Type stays empty, the JSON is byte-identical to Huma's +// default. Absence of a code is the signal for "legacy / not-yet-converted +// call site"; converted sites mint their error through the apierr +// constructors instead, which bypass this override entirely. +// +// Overriding NewError also covers NewErrorWithContext: Huma's default +// NewErrorWithContext delegates to the NewError package var at call time, and +// the serving path (WriteErr) goes through NewErrorWithContext. +func init() { + base := huma.NewError + huma.NewError = func(status int, msg string, errs ...error) huma.StatusError { + // Reuse Huma's own construction so the embedded model — including its + // exact errs→ErrorDetail conversion — is never reimplemented here. + model, ok := base(status, msg, errs...).(*huma.ErrorModel) + if !ok { + // A non-default base (another override chained ahead of us) — leave it + // untouched rather than guess at its shape. + return base(status, msg, errs...) + } + if msg == validationFailedDetail { + return &apierr.ErrorModel{ + ErrorModel: huma.ErrorModel{ + Type: apierr.ValidationFailed.URN(), + Title: apierr.ValidationFailed.Title, + Status: model.Status, + Detail: model.Detail, + Instance: model.Instance, + Errors: model.Errors, + }, + Code: apierr.ValidationFailed.Code, + } + } + return &apierr.ErrorModel{ErrorModel: *model} + } +} diff --git a/internal/api/genclient/client_gen.go b/internal/api/genclient/client_gen.go index 27a9fe1331..ca621b7066 100644 --- a/internal/api/genclient/client_gen.go +++ b/internal/api/genclient/client_gen.go @@ -357,6 +357,27 @@ func (e GetV0CityByCityNameExtmsgTranscriptParamsOrder) Valid() bool { } } +// Defines values for PostV0CityByCityNameRigByNameByActionParamsAction. +const ( + Restart PostV0CityByCityNameRigByNameByActionParamsAction = "restart" + Resume PostV0CityByCityNameRigByNameByActionParamsAction = "resume" + Suspend PostV0CityByCityNameRigByNameByActionParamsAction = "suspend" +) + +// Valid indicates whether the value is a known member of the PostV0CityByCityNameRigByNameByActionParamsAction enum. +func (e PostV0CityByCityNameRigByNameByActionParamsAction) Valid() bool { + switch e { + case Restart: + return true + case Resume: + return true + case Suspend: + return true + default: + return false + } +} + // AdapterCapabilities defines model for AdapterCapabilities. type AdapterCapabilities struct { MaxMessageLength int64 `json:"MaxMessageLength"` @@ -1055,6 +1076,9 @@ type ErrorDetail struct { // ErrorModel defines model for ErrorModel. type ErrorModel struct { + // Code Stable machine-readable error code (the final segment of the type URN). + Code *string `json:"code,omitempty"` + // Detail A human-readable explanation specific to this occurrence of the problem. Detail *string `json:"detail,omitempty"` @@ -6780,6 +6804,9 @@ type PostV0CityByCityNameRigByNameByActionParams struct { XGCRequest string `json:"X-GC-Request"` } +// PostV0CityByCityNameRigByNameByActionParamsAction defines parameters for PostV0CityByCityNameRigByNameByAction. +type PostV0CityByCityNameRigByNameByActionParamsAction string + // GetV0CityByCityNameRigsParams defines parameters for GetV0CityByCityNameRigs. type GetV0CityByCityNameRigsParams struct { // Index Event sequence number; when provided, blocks until a newer event arrives. @@ -13402,7 +13429,7 @@ type ClientInterface interface { PatchV0CityByCityNameRigByName(ctx context.Context, cityName string, name string, params *PatchV0CityByCityNameRigByNameParams, body PatchV0CityByCityNameRigByNameJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) // PostV0CityByCityNameRigByNameByAction request - PostV0CityByCityNameRigByNameByAction(ctx context.Context, cityName string, name string, action string, params *PostV0CityByCityNameRigByNameByActionParams, reqEditors ...RequestEditorFn) (*http.Response, error) + PostV0CityByCityNameRigByNameByAction(ctx context.Context, cityName string, name string, action PostV0CityByCityNameRigByNameByActionParamsAction, params *PostV0CityByCityNameRigByNameByActionParams, reqEditors ...RequestEditorFn) (*http.Response, error) // GetV0CityByCityNameRigs request GetV0CityByCityNameRigs(ctx context.Context, cityName string, params *GetV0CityByCityNameRigsParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -15394,7 +15421,7 @@ func (c *Client) PatchV0CityByCityNameRigByName(ctx context.Context, cityName st return c.Client.Do(req) } -func (c *Client) PostV0CityByCityNameRigByNameByAction(ctx context.Context, cityName string, name string, action string, params *PostV0CityByCityNameRigByNameByActionParams, reqEditors ...RequestEditorFn) (*http.Response, error) { +func (c *Client) PostV0CityByCityNameRigByNameByAction(ctx context.Context, cityName string, name string, action PostV0CityByCityNameRigByNameByActionParamsAction, params *PostV0CityByCityNameRigByNameByActionParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewPostV0CityByCityNameRigByNameByActionRequest(c.Server, cityName, name, action, params) if err != nil { return nil, err @@ -23514,7 +23541,7 @@ func NewPatchV0CityByCityNameRigByNameRequestWithBody(server string, cityName st } // NewPostV0CityByCityNameRigByNameByActionRequest generates requests for PostV0CityByCityNameRigByNameByAction -func NewPostV0CityByCityNameRigByNameByActionRequest(server string, cityName string, name string, action string, params *PostV0CityByCityNameRigByNameByActionParams) (*http.Request, error) { +func NewPostV0CityByCityNameRigByNameByActionRequest(server string, cityName string, name string, action PostV0CityByCityNameRigByNameByActionParamsAction, params *PostV0CityByCityNameRigByNameByActionParams) (*http.Request, error) { var err error var pathParam0 string @@ -26259,7 +26286,7 @@ type ClientWithResponsesInterface interface { PatchV0CityByCityNameRigByNameWithResponse(ctx context.Context, cityName string, name string, params *PatchV0CityByCityNameRigByNameParams, body PatchV0CityByCityNameRigByNameJSONRequestBody, reqEditors ...RequestEditorFn) (*PatchV0CityByCityNameRigByNameResponse, error) // PostV0CityByCityNameRigByNameByActionWithResponse request - PostV0CityByCityNameRigByNameByActionWithResponse(ctx context.Context, cityName string, name string, action string, params *PostV0CityByCityNameRigByNameByActionParams, reqEditors ...RequestEditorFn) (*PostV0CityByCityNameRigByNameByActionResponse, error) + PostV0CityByCityNameRigByNameByActionWithResponse(ctx context.Context, cityName string, name string, action PostV0CityByCityNameRigByNameByActionParamsAction, params *PostV0CityByCityNameRigByNameByActionParams, reqEditors ...RequestEditorFn) (*PostV0CityByCityNameRigByNameByActionResponse, error) // GetV0CityByCityNameRigsWithResponse request GetV0CityByCityNameRigsWithResponse(ctx context.Context, cityName string, params *GetV0CityByCityNameRigsParams, reqEditors ...RequestEditorFn) (*GetV0CityByCityNameRigsResponse, error) @@ -26449,10 +26476,12 @@ func (r PostV0CityResponse) StatusCode() int { } type GetV0CityByCityNameResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *CityGetResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *CityGetResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -26472,10 +26501,16 @@ func (r GetV0CityByCityNameResponse) StatusCode() int { } type PatchV0CityByCityNameResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -26495,10 +26530,17 @@ func (r PatchV0CityByCityNameResponse) StatusCode() int { } type DeleteV0CityByCityNameAgentByBaseResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -26518,10 +26560,12 @@ func (r DeleteV0CityByCityNameAgentByBaseResponse) StatusCode() int { } type GetV0CityByCityNameAgentByBaseResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *AgentResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *AgentResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -26541,10 +26585,17 @@ func (r GetV0CityByCityNameAgentByBaseResponse) StatusCode() int { } type PatchV0CityByCityNameAgentByBaseResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -26564,10 +26615,12 @@ func (r PatchV0CityByCityNameAgentByBaseResponse) StatusCode() int { } type GetV0CityByCityNameAgentByBaseOutputResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *AgentOutputResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *AgentOutputResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -26609,10 +26662,16 @@ func (r StreamAgentOutputResponse) StatusCode() int { } type PostV0CityByCityNameAgentByBaseByActionResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -26632,10 +26691,17 @@ func (r PostV0CityByCityNameAgentByBaseByActionResponse) StatusCode() int { } type DeleteV0CityByCityNameAgentByDirByBaseResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -26655,10 +26721,12 @@ func (r DeleteV0CityByCityNameAgentByDirByBaseResponse) StatusCode() int { } type GetV0CityByCityNameAgentByDirByBaseResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *AgentResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *AgentResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -26678,10 +26746,17 @@ func (r GetV0CityByCityNameAgentByDirByBaseResponse) StatusCode() int { } type PatchV0CityByCityNameAgentByDirByBaseResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -26701,10 +26776,12 @@ func (r PatchV0CityByCityNameAgentByDirByBaseResponse) StatusCode() int { } type GetV0CityByCityNameAgentByDirByBaseOutputResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *AgentOutputResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *AgentOutputResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -26746,10 +26823,16 @@ func (r StreamAgentOutputQualifiedResponse) StatusCode() int { } type PostV0CityByCityNameAgentByDirByBaseByActionResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -26769,10 +26852,12 @@ func (r PostV0CityByCityNameAgentByDirByBaseByActionResponse) StatusCode() int { } type GetV0CityByCityNameAgentsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ListBodyAgentResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ListBodyAgentResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -26792,10 +26877,19 @@ func (r GetV0CityByCityNameAgentsResponse) StatusCode() int { } type CreateAgentResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *AgentCreatedOutputBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON201 *AgentCreatedOutputBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel + ApplicationproblemJSON503 *ErrorModel + ApplicationproblemJSON504 *ErrorModel } // Status returns HTTPResponse.Status @@ -26815,10 +26909,15 @@ func (r CreateAgentResponse) StatusCode() int { } type DeleteV0CityByCityNameBeadByIdResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -26838,10 +26937,13 @@ func (r DeleteV0CityByCityNameBeadByIdResponse) StatusCode() int { } type GetV0CityByCityNameBeadByIdResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *Bead - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *Bead + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -26861,10 +26963,16 @@ func (r GetV0CityByCityNameBeadByIdResponse) StatusCode() int { } type PatchV0CityByCityNameBeadByIdResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -26884,10 +26992,16 @@ func (r PatchV0CityByCityNameBeadByIdResponse) StatusCode() int { } type PostV0CityByCityNameBeadByIdAssignResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *map[string]string - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *map[string]string + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -26907,10 +27021,15 @@ func (r PostV0CityByCityNameBeadByIdAssignResponse) StatusCode() int { } type PostV0CityByCityNameBeadByIdCloseResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -26930,10 +27049,12 @@ func (r PostV0CityByCityNameBeadByIdCloseResponse) StatusCode() int { } type GetV0CityByCityNameBeadByIdDepsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *BeadDepsResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *BeadDepsResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -26953,10 +27074,15 @@ func (r GetV0CityByCityNameBeadByIdDepsResponse) StatusCode() int { } type PostV0CityByCityNameBeadByIdReopenResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -26976,10 +27102,16 @@ func (r PostV0CityByCityNameBeadByIdReopenResponse) StatusCode() int { } type PostV0CityByCityNameBeadByIdUpdateResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -26999,10 +27131,13 @@ func (r PostV0CityByCityNameBeadByIdUpdateResponse) StatusCode() int { } type GetV0CityByCityNameBeadsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ListBodyBead - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ListBodyBead + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27022,10 +27157,16 @@ func (r GetV0CityByCityNameBeadsResponse) StatusCode() int { } type CreateBeadResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *Bead - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON201 *Bead + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -27045,10 +27186,12 @@ func (r CreateBeadResponse) StatusCode() int { } type GetV0CityByCityNameBeadsGraphByRootIdResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *BeadGraphResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *BeadGraphResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -27068,10 +27211,13 @@ func (r GetV0CityByCityNameBeadsGraphByRootIdResponse) StatusCode() int { } type GetV0CityByCityNameBeadsReadyResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ListBodyBead - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ListBodyBead + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27091,10 +27237,12 @@ func (r GetV0CityByCityNameBeadsReadyResponse) StatusCode() int { } type GetV0CityByCityNameConfigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ConfigResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ConfigResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -27114,10 +27262,12 @@ func (r GetV0CityByCityNameConfigResponse) StatusCode() int { } type GetV0CityByCityNameConfigDefaultsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ConfigResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ConfigResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -27137,10 +27287,12 @@ func (r GetV0CityByCityNameConfigDefaultsResponse) StatusCode() int { } type GetV0CityByCityNameConfigExplainResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ConfigExplainResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ConfigExplainResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -27160,10 +27312,12 @@ func (r GetV0CityByCityNameConfigExplainResponse) StatusCode() int { } type GetV0CityByCityNameConfigValidateResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ConfigValidateOutputBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ConfigValidateOutputBody + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -27183,10 +27337,15 @@ func (r GetV0CityByCityNameConfigValidateResponse) StatusCode() int { } type DeleteV0CityByCityNameConvoyByIdResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -27206,10 +27365,13 @@ func (r DeleteV0CityByCityNameConvoyByIdResponse) StatusCode() int { } type GetV0CityByCityNameConvoyByIdResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ConvoyGetResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ConvoyGetResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27229,10 +27391,15 @@ func (r GetV0CityByCityNameConvoyByIdResponse) StatusCode() int { } type PostV0CityByCityNameConvoyByIdAddResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -27252,10 +27419,14 @@ func (r PostV0CityByCityNameConvoyByIdAddResponse) StatusCode() int { } type GetV0CityByCityNameConvoyByIdCheckResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ConvoyCheckResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ConvoyCheckResponse + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27275,10 +27446,15 @@ func (r GetV0CityByCityNameConvoyByIdCheckResponse) StatusCode() int { } type PostV0CityByCityNameConvoyByIdCloseResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -27298,10 +27474,15 @@ func (r PostV0CityByCityNameConvoyByIdCloseResponse) StatusCode() int { } type PostV0CityByCityNameConvoyByIdRemoveResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -27321,10 +27502,13 @@ func (r PostV0CityByCityNameConvoyByIdRemoveResponse) StatusCode() int { } type GetV0CityByCityNameConvoysResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ListBodyBead - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ListBodyBead + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27344,10 +27528,15 @@ func (r GetV0CityByCityNameConvoysResponse) StatusCode() int { } type CreateConvoyResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *Bead - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON201 *Bead + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -27367,10 +27556,13 @@ func (r CreateConvoyResponse) StatusCode() int { } type GetV0CityByCityNameEventsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ListBodyWireEvent - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ListBodyWireEvent + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -27390,10 +27582,15 @@ func (r GetV0CityByCityNameEventsResponse) StatusCode() int { } type EmitEventResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *EventEmitOutputBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON201 *EventEmitOutputBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27413,10 +27610,15 @@ func (r EmitEventResponse) StatusCode() int { } type RotateEventsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *EventRotateResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *EventRotateResponse + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON405 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -27458,10 +27660,15 @@ func (r StreamEventsResponse) StatusCode() int { } type DeleteV0CityByCityNameExtmsgAdaptersResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27481,10 +27688,13 @@ func (r DeleteV0CityByCityNameExtmsgAdaptersResponse) StatusCode() int { } type GetV0CityByCityNameExtmsgAdaptersResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ListBodyExtmsgAdapterInfo - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ListBodyExtmsgAdapterInfo + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27504,10 +27714,15 @@ func (r GetV0CityByCityNameExtmsgAdaptersResponse) StatusCode() int { } type RegisterExtmsgAdapterResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *ExtMsgAdapterRegisterOutputBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON201 *ExtMsgAdapterRegisterOutputBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27527,10 +27742,17 @@ func (r RegisterExtmsgAdapterResponse) StatusCode() int { } type PostV0CityByCityNameExtmsgBindResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *SessionBindingRecord - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *SessionBindingRecord + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27550,10 +27772,14 @@ func (r PostV0CityByCityNameExtmsgBindResponse) StatusCode() int { } type GetV0CityByCityNameExtmsgBindingsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ListBodySessionBindingRecord - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ListBodySessionBindingRecord + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27573,10 +27799,13 @@ func (r GetV0CityByCityNameExtmsgBindingsResponse) StatusCode() int { } type GetV0CityByCityNameExtmsgGroupsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ConversationGroupRecord - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ConversationGroupRecord + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27596,10 +27825,15 @@ func (r GetV0CityByCityNameExtmsgGroupsResponse) StatusCode() int { } type EnsureExtmsgGroupResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *ConversationGroupRecord - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON201 *ConversationGroupRecord + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27619,10 +27853,16 @@ func (r EnsureExtmsgGroupResponse) StatusCode() int { } type PostV0CityByCityNameExtmsgInboundResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *InboundResult - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *InboundResult + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27642,10 +27882,15 @@ func (r PostV0CityByCityNameExtmsgInboundResponse) StatusCode() int { } type PostV0CityByCityNameExtmsgOutboundResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OutboundResult - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OutboundResult + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27665,10 +27910,15 @@ func (r PostV0CityByCityNameExtmsgOutboundResponse) StatusCode() int { } type DeleteV0CityByCityNameExtmsgParticipantsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27688,10 +27938,15 @@ func (r DeleteV0CityByCityNameExtmsgParticipantsResponse) StatusCode() int { } type PostV0CityByCityNameExtmsgParticipantsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ConversationGroupParticipant - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ConversationGroupParticipant + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27711,10 +27966,13 @@ func (r PostV0CityByCityNameExtmsgParticipantsResponse) StatusCode() int { } type GetV0CityByCityNameExtmsgTranscriptResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ListBodyConversationTranscriptRecord - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ListBodyConversationTranscriptRecord + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27734,10 +27992,15 @@ func (r GetV0CityByCityNameExtmsgTranscriptResponse) StatusCode() int { } type PostV0CityByCityNameExtmsgTranscriptAckResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27757,10 +28020,16 @@ func (r PostV0CityByCityNameExtmsgTranscriptAckResponse) StatusCode() int { } type PostV0CityByCityNameExtmsgUnbindResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ExtMsgUnbindBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ExtMsgUnbindBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27780,10 +28049,14 @@ func (r PostV0CityByCityNameExtmsgUnbindResponse) StatusCode() int { } type GetV0CityByCityNameFormulaByNameResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *FormulaDetailResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *FormulaDetailResponse + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27803,10 +28076,14 @@ func (r GetV0CityByCityNameFormulaByNameResponse) StatusCode() int { } type GetV0CityByCityNameFormulasResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *FormulaListBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *FormulaListBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27826,10 +28103,14 @@ func (r GetV0CityByCityNameFormulasResponse) StatusCode() int { } type GetV0CityByCityNameFormulasFeedResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *FormulaFeedBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *FormulaFeedBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27849,10 +28130,16 @@ func (r GetV0CityByCityNameFormulasFeedResponse) StatusCode() int { } type DeleteV0CityByCityNameFormulasByNameResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -27872,10 +28159,14 @@ func (r DeleteV0CityByCityNameFormulasByNameResponse) StatusCode() int { } type GetV0CityByCityNameFormulasByNameResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *FormulaDetailResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *FormulaDetailResponse + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27895,10 +28186,17 @@ func (r GetV0CityByCityNameFormulasByNameResponse) StatusCode() int { } type PutV0CityByCityNameFormulasByNameResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON413 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -27918,10 +28216,16 @@ func (r PutV0CityByCityNameFormulasByNameResponse) StatusCode() int { } type PostV0CityByCityNameFormulasByNamePreviewResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *FormulaDetailResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *FormulaDetailResponse + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27941,10 +28245,14 @@ func (r PostV0CityByCityNameFormulasByNamePreviewResponse) StatusCode() int { } type GetV0CityByCityNameFormulasByNameRunsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *FormulaRunsResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *FormulaRunsResponse + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -27964,10 +28272,14 @@ func (r GetV0CityByCityNameFormulasByNameRunsResponse) StatusCode() int { } type GetV0CityByCityNameFormulasByNameSourceResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *FormulaSourceOutputBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *FormulaSourceOutputBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -27987,10 +28299,15 @@ func (r GetV0CityByCityNameFormulasByNameSourceResponse) StatusCode() int { } type PostV0CityByCityNameFormulasByNameValidateResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *FormulaValidateOutputBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *FormulaValidateOutputBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON413 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28010,10 +28327,12 @@ func (r PostV0CityByCityNameFormulasByNameValidateResponse) StatusCode() int { } type GetV0CityByCityNameHealthResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *HealthOutputBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *HealthOutputBody + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28033,10 +28352,14 @@ func (r GetV0CityByCityNameHealthResponse) StatusCode() int { } type GetV0CityByCityNameMailResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *MailListBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *MailListBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -28056,10 +28379,16 @@ func (r GetV0CityByCityNameMailResponse) StatusCode() int { } type SendMailResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *Message - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON201 *Message + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28079,10 +28408,13 @@ func (r SendMailResponse) StatusCode() int { } type GetV0CityByCityNameMailCountResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *MailCountOutputBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *MailCountOutputBody + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -28102,10 +28434,13 @@ func (r GetV0CityByCityNameMailCountResponse) StatusCode() int { } type GetV0CityByCityNameMailThreadByIdResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *MailListBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *MailListBody + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -28125,10 +28460,14 @@ func (r GetV0CityByCityNameMailThreadByIdResponse) StatusCode() int { } type DeleteV0CityByCityNameMailByIdResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28148,10 +28487,13 @@ func (r DeleteV0CityByCityNameMailByIdResponse) StatusCode() int { } type GetV0CityByCityNameMailByIdResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *Message - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *Message + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -28171,10 +28513,14 @@ func (r GetV0CityByCityNameMailByIdResponse) StatusCode() int { } type PostV0CityByCityNameMailByIdArchiveResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28194,10 +28540,14 @@ func (r PostV0CityByCityNameMailByIdArchiveResponse) StatusCode() int { } type PostV0CityByCityNameMailByIdMarkUnreadResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28217,10 +28567,14 @@ func (r PostV0CityByCityNameMailByIdMarkUnreadResponse) StatusCode() int { } type PostV0CityByCityNameMailByIdReadResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28240,10 +28594,14 @@ func (r PostV0CityByCityNameMailByIdReadResponse) StatusCode() int { } type ReplyMailResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *Message - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON201 *Message + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28263,10 +28621,16 @@ func (r ReplyMailResponse) StatusCode() int { } type TriggerMaintenanceDoltGcResponse struct { - Body []byte - HTTPResponse *http.Response - JSON202 *MaintenanceTriggerBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON202 *MaintenanceTriggerBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -28286,10 +28650,13 @@ func (r TriggerMaintenanceDoltGcResponse) StatusCode() int { } type GetV0CityByCityNameMaintenanceStatusResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *MaintenanceStatusBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *MaintenanceStatusBody + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -28309,10 +28676,13 @@ func (r GetV0CityByCityNameMaintenanceStatusResponse) StatusCode() int { } type GetV0CityByCityNameOrderHistoryByBeadIdResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OrderHistoryDetailResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OrderHistoryDetailResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -28332,10 +28702,13 @@ func (r GetV0CityByCityNameOrderHistoryByBeadIdResponse) StatusCode() int { } type GetV0CityByCityNameOrderByNameResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OrderResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OrderResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28355,10 +28728,17 @@ func (r GetV0CityByCityNameOrderByNameResponse) StatusCode() int { } type PostV0CityByCityNameOrderByNameDisableResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -28378,10 +28758,17 @@ func (r PostV0CityByCityNameOrderByNameDisableResponse) StatusCode() int { } type PostV0CityByCityNameOrderByNameEnableResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -28401,10 +28788,15 @@ func (r PostV0CityByCityNameOrderByNameEnableResponse) StatusCode() int { } type PostV0CityByCityNameOrderByNameRunResponse struct { - Body []byte - HTTPResponse *http.Response - JSON202 *OrderRunOutputBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON202 *OrderRunOutputBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -28424,10 +28816,12 @@ func (r PostV0CityByCityNameOrderByNameRunResponse) StatusCode() int { } type GetV0CityByCityNameOrdersResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OrderListBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OrderListBody + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28447,10 +28841,12 @@ func (r GetV0CityByCityNameOrdersResponse) StatusCode() int { } type GetV0CityByCityNameOrdersCheckResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OrderCheckListBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OrderCheckListBody + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28470,10 +28866,13 @@ func (r GetV0CityByCityNameOrdersCheckResponse) StatusCode() int { } type GetV0CityByCityNameOrdersFeedResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OrdersFeedBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OrdersFeedBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28493,10 +28892,14 @@ func (r GetV0CityByCityNameOrdersFeedResponse) StatusCode() int { } type GetV0CityByCityNameOrdersHistoryResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OrderHistoryListBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OrderHistoryListBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -28516,10 +28919,13 @@ func (r GetV0CityByCityNameOrdersHistoryResponse) StatusCode() int { } type GetV0CityByCityNamePacksResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *PackListBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *PackListBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28539,10 +28945,17 @@ func (r GetV0CityByCityNamePacksResponse) StatusCode() int { } type AddPackResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *PackAddedOutputBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON201 *PackAddedOutputBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON502 *ErrorModel } // Status returns HTTPResponse.Status @@ -28562,10 +28975,15 @@ func (r AddPackResponse) StatusCode() int { } type DeleteV0CityByCityNamePacksByNameResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *PackRemovedOutputBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *PackRemovedOutputBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28585,10 +29003,16 @@ func (r DeleteV0CityByCityNamePacksByNameResponse) StatusCode() int { } type DeleteV0CityByCityNamePatchesAgentByBaseResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *PatchDeletedResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *PatchDeletedResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -28608,10 +29032,12 @@ func (r DeleteV0CityByCityNamePatchesAgentByBaseResponse) StatusCode() int { } type GetV0CityByCityNamePatchesAgentByBaseResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *AgentPatch - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *AgentPatch + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28631,10 +29057,16 @@ func (r GetV0CityByCityNamePatchesAgentByBaseResponse) StatusCode() int { } type DeleteV0CityByCityNamePatchesAgentByDirByBaseResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *PatchDeletedResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *PatchDeletedResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -28654,10 +29086,12 @@ func (r DeleteV0CityByCityNamePatchesAgentByDirByBaseResponse) StatusCode() int } type GetV0CityByCityNamePatchesAgentByDirByBaseResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *AgentPatch - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *AgentPatch + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28677,10 +29111,12 @@ func (r GetV0CityByCityNamePatchesAgentByDirByBaseResponse) StatusCode() int { } type GetV0CityByCityNamePatchesAgentsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ListBodyAgentPatch - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ListBodyAgentPatch + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28700,10 +29136,16 @@ func (r GetV0CityByCityNamePatchesAgentsResponse) StatusCode() int { } type PutV0CityByCityNamePatchesAgentsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *PatchOKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *PatchOKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -28723,10 +29165,16 @@ func (r PutV0CityByCityNamePatchesAgentsResponse) StatusCode() int { } type DeleteV0CityByCityNamePatchesProviderByNameResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *PatchDeletedResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *PatchDeletedResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -28746,10 +29194,12 @@ func (r DeleteV0CityByCityNamePatchesProviderByNameResponse) StatusCode() int { } type GetV0CityByCityNamePatchesProviderByNameResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ProviderPatch - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ProviderPatch + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28769,10 +29219,12 @@ func (r GetV0CityByCityNamePatchesProviderByNameResponse) StatusCode() int { } type GetV0CityByCityNamePatchesProvidersResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ListBodyProviderPatch - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ListBodyProviderPatch + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28792,10 +29244,16 @@ func (r GetV0CityByCityNamePatchesProvidersResponse) StatusCode() int { } type PutV0CityByCityNamePatchesProvidersResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *PatchOKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *PatchOKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -28815,10 +29273,16 @@ func (r PutV0CityByCityNamePatchesProvidersResponse) StatusCode() int { } type DeleteV0CityByCityNamePatchesRigByNameResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *PatchDeletedResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *PatchDeletedResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -28838,10 +29302,12 @@ func (r DeleteV0CityByCityNamePatchesRigByNameResponse) StatusCode() int { } type GetV0CityByCityNamePatchesRigByNameResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *RigPatch - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *RigPatch + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28861,10 +29327,12 @@ func (r GetV0CityByCityNamePatchesRigByNameResponse) StatusCode() int { } type GetV0CityByCityNamePatchesRigsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ListBodyRigPatch - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ListBodyRigPatch + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28884,10 +29352,16 @@ func (r GetV0CityByCityNamePatchesRigsResponse) StatusCode() int { } type PutV0CityByCityNamePatchesRigsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *PatchOKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *PatchOKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -28907,10 +29381,13 @@ func (r PutV0CityByCityNamePatchesRigsResponse) StatusCode() int { } type GetV0CityByCityNamePendingResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ListBodyCityPendingEntry - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ListBodyCityPendingEntry + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -28930,10 +29407,13 @@ func (r GetV0CityByCityNamePendingResponse) StatusCode() int { } type GetV0CityByCityNameProviderReadinessResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ProviderReadinessResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ProviderReadinessResponse + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28953,10 +29433,17 @@ func (r GetV0CityByCityNameProviderReadinessResponse) StatusCode() int { } type DeleteV0CityByCityNameProviderByNameResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -28976,10 +29463,12 @@ func (r DeleteV0CityByCityNameProviderByNameResponse) StatusCode() int { } type GetV0CityByCityNameProviderByNameResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ProviderResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ProviderResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -28999,10 +29488,17 @@ func (r GetV0CityByCityNameProviderByNameResponse) StatusCode() int { } type PatchV0CityByCityNameProviderByNameResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -29022,10 +29518,12 @@ func (r PatchV0CityByCityNameProviderByNameResponse) StatusCode() int { } type GetV0CityByCityNameProvidersResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ListBodyProviderResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ListBodyProviderResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -29045,10 +29543,17 @@ func (r GetV0CityByCityNameProvidersResponse) StatusCode() int { } type CreateProviderResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *ProviderCreatedOutputBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON201 *ProviderCreatedOutputBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -29068,10 +29573,12 @@ func (r CreateProviderResponse) StatusCode() int { } type GetV0CityByCityNameProvidersPublicResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ProviderPublicListBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ProviderPublicListBody + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -29091,10 +29598,13 @@ func (r GetV0CityByCityNameProvidersPublicResponse) StatusCode() int { } type GetV0CityByCityNameReadinessResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ReadinessResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ReadinessResponse + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -29114,10 +29624,16 @@ func (r GetV0CityByCityNameReadinessResponse) StatusCode() int { } type DeleteV0CityByCityNameRigByNameResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -29137,10 +29653,12 @@ func (r DeleteV0CityByCityNameRigByNameResponse) StatusCode() int { } type GetV0CityByCityNameRigByNameResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *RigResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *RigResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -29160,10 +29678,16 @@ func (r GetV0CityByCityNameRigByNameResponse) StatusCode() int { } type PatchV0CityByCityNameRigByNameResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -29183,10 +29707,15 @@ func (r PatchV0CityByCityNameRigByNameResponse) StatusCode() int { } type PostV0CityByCityNameRigByNameByActionResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *RigActionBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *RigActionBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -29206,10 +29735,13 @@ func (r PostV0CityByCityNameRigByNameByActionResponse) StatusCode() int { } type GetV0CityByCityNameRigsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ListBodyRigResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ListBodyRigResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -29229,10 +29761,17 @@ func (r GetV0CityByCityNameRigsResponse) StatusCode() int { } type CreateRigResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *RigCreatedOutputBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON201 *RigCreatedOutputBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel } // Status returns HTTPResponse.Status @@ -29252,10 +29791,12 @@ func (r CreateRigResponse) StatusCode() int { } type GetV0CityByCityNameServiceByNameResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *Status - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *Status + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -29275,10 +29816,14 @@ func (r GetV0CityByCityNameServiceByNameResponse) StatusCode() int { } type PostV0CityByCityNameServiceByNameRestartResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ServiceRestartOutputBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ServiceRestartOutputBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -29298,10 +29843,12 @@ func (r PostV0CityByCityNameServiceByNameRestartResponse) StatusCode() int { } type GetV0CityByCityNameServicesResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ListBodyStatus - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ListBodyStatus + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -29321,10 +29868,14 @@ func (r GetV0CityByCityNameServicesResponse) StatusCode() int { } type GetV0CityByCityNameSessionByIdResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *SessionResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *SessionResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -29344,10 +29895,17 @@ func (r GetV0CityByCityNameSessionByIdResponse) StatusCode() int { } type PatchV0CityByCityNameSessionByIdResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *SessionResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *SessionResponse + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -29367,10 +29925,14 @@ func (r PatchV0CityByCityNameSessionByIdResponse) StatusCode() int { } type GetV0CityByCityNameSessionByIdAgentsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *SessionAgentListResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *SessionAgentListResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -29390,10 +29952,15 @@ func (r GetV0CityByCityNameSessionByIdAgentsResponse) StatusCode() int { } type GetV0CityByCityNameSessionByIdAgentsByAgentIdResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *SessionAgentGetResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *SessionAgentGetResponse + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -29413,10 +29980,16 @@ func (r GetV0CityByCityNameSessionByIdAgentsByAgentIdResponse) StatusCode() int } type PostV0CityByCityNameSessionByIdCloseResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -29436,10 +30009,16 @@ func (r PostV0CityByCityNameSessionByIdCloseResponse) StatusCode() int { } type PostV0CityByCityNameSessionByIdKillResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKWithIDResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKWithIDResponseBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -29459,10 +30038,15 @@ func (r PostV0CityByCityNameSessionByIdKillResponse) StatusCode() int { } type SendSessionMessageResponse struct { - Body []byte - HTTPResponse *http.Response - JSON202 *AsyncAcceptedBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON202 *AsyncAcceptedBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -29482,10 +30066,14 @@ func (r SendSessionMessageResponse) StatusCode() int { } type GetV0CityByCityNameSessionByIdPendingResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *SessionPendingResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *SessionPendingResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -29505,10 +30093,18 @@ func (r GetV0CityByCityNameSessionByIdPendingResponse) StatusCode() int { } type PostV0CityByCityNameSessionByIdPermissionModeResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *SessionResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *SessionResponse + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -29528,10 +30124,17 @@ func (r PostV0CityByCityNameSessionByIdPermissionModeResponse) StatusCode() int } type PostV0CityByCityNameSessionByIdRenameResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *SessionResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *SessionResponse + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -29551,10 +30154,17 @@ func (r PostV0CityByCityNameSessionByIdRenameResponse) StatusCode() int { } type RespondSessionResponse struct { - Body []byte - HTTPResponse *http.Response - JSON202 *SessionRespondOutputBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON202 *SessionRespondOutputBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON501 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -29574,10 +30184,16 @@ func (r RespondSessionResponse) StatusCode() int { } type PostV0CityByCityNameSessionByIdStopResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKWithIDResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKWithIDResponseBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -29619,10 +30235,15 @@ func (r StreamSessionResponse) StatusCode() int { } type SubmitSessionResponse struct { - Body []byte - HTTPResponse *http.Response - JSON202 *AsyncAcceptedBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON202 *AsyncAcceptedBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -29642,10 +30263,16 @@ func (r SubmitSessionResponse) StatusCode() int { } type PostV0CityByCityNameSessionByIdSuspendResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKResponseBody + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -29665,10 +30292,14 @@ func (r PostV0CityByCityNameSessionByIdSuspendResponse) StatusCode() int { } type GetV0CityByCityNameSessionByIdTranscriptResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *SessionTranscriptGetResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *SessionTranscriptGetResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -29688,10 +30319,17 @@ func (r GetV0CityByCityNameSessionByIdTranscriptResponse) StatusCode() int { } type PostV0CityByCityNameSessionByIdWakeResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *OKWithIDResponseBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *OKWithIDResponseBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -29711,10 +30349,13 @@ func (r PostV0CityByCityNameSessionByIdWakeResponse) StatusCode() int { } type GetV0CityByCityNameSessionsResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *ListBodySessionResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *ListBodySessionResponse + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -29734,10 +30375,16 @@ func (r GetV0CityByCityNameSessionsResponse) StatusCode() int { } type CreateSessionResponse struct { - Body []byte - HTTPResponse *http.Response - JSON202 *AsyncAcceptedBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON202 *AsyncAcceptedBody + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -29757,10 +30404,16 @@ func (r CreateSessionResponse) StatusCode() int { } type PostV0CityByCityNameSlingResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *SlingResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *SlingResponse + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON409 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -29780,10 +30433,13 @@ func (r PostV0CityByCityNameSlingResponse) StatusCode() int { } type GetV0CityByCityNameStatusResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *StatusBody - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *StatusBody + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel + ApplicationproblemJSON503 *ErrorModel } // Status returns HTTPResponse.Status @@ -29826,10 +30482,15 @@ func (r PostV0CityByCityNameUnregisterResponse) StatusCode() int { } type DeleteV0CityByCityNameWorkflowByWorkflowIdResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *WorkflowDeleteResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *WorkflowDeleteResponse + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON401 *ErrorModel + ApplicationproblemJSON403 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -29849,10 +30510,13 @@ func (r DeleteV0CityByCityNameWorkflowByWorkflowIdResponse) StatusCode() int { } type GetV0CityByCityNameWorkflowByWorkflowIdResponse struct { - Body []byte - HTTPResponse *http.Response - JSON200 *WorkflowSnapshotResponse - ApplicationproblemJSONDefault *ErrorModel + Body []byte + HTTPResponse *http.Response + JSON200 *WorkflowSnapshotResponse + ApplicationproblemJSON400 *ErrorModel + ApplicationproblemJSON404 *ErrorModel + ApplicationproblemJSON422 *ErrorModel + ApplicationproblemJSON500 *ErrorModel } // Status returns HTTPResponse.Status @@ -31333,7 +31997,7 @@ func (c *ClientWithResponses) PatchV0CityByCityNameRigByNameWithResponse(ctx con } // PostV0CityByCityNameRigByNameByActionWithResponse request returning *PostV0CityByCityNameRigByNameByActionResponse -func (c *ClientWithResponses) PostV0CityByCityNameRigByNameByActionWithResponse(ctx context.Context, cityName string, name string, action string, params *PostV0CityByCityNameRigByNameByActionParams, reqEditors ...RequestEditorFn) (*PostV0CityByCityNameRigByNameByActionResponse, error) { +func (c *ClientWithResponses) PostV0CityByCityNameRigByNameByActionWithResponse(ctx context.Context, cityName string, name string, action PostV0CityByCityNameRigByNameByActionParamsAction, params *PostV0CityByCityNameRigByNameByActionParams, reqEditors ...RequestEditorFn) (*PostV0CityByCityNameRigByNameByActionResponse, error) { rsp, err := c.PostV0CityByCityNameRigByNameByAction(ctx, cityName, name, action, params, reqEditors...) if err != nil { return nil, err @@ -31830,12 +32494,26 @@ func ParseGetV0CityByCityNameResponse(rsp *http.Response) (*GetV0CityByCityNameR } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -31863,12 +32541,54 @@ func ParsePatchV0CityByCityNameResponse(rsp *http.Response) (*PatchV0CityByCityN } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -31896,12 +32616,61 @@ func ParseDeleteV0CityByCityNameAgentByBaseResponse(rsp *http.Response) (*Delete } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -31929,12 +32698,26 @@ func ParseGetV0CityByCityNameAgentByBaseResponse(rsp *http.Response) (*GetV0City } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -31962,12 +32745,61 @@ func ParsePatchV0CityByCityNameAgentByBaseResponse(rsp *http.Response) (*PatchV0 } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -31995,12 +32827,26 @@ func ParseGetV0CityByCityNameAgentByBaseOutputResponse(rsp *http.Response) (*Get } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -32054,12 +32900,54 @@ func ParsePostV0CityByCityNameAgentByBaseByActionResponse(rsp *http.Response) (* } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -32087,12 +32975,61 @@ func ParseDeleteV0CityByCityNameAgentByDirByBaseResponse(rsp *http.Response) (*D } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -32120,12 +33057,26 @@ func ParseGetV0CityByCityNameAgentByDirByBaseResponse(rsp *http.Response) (*GetV } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -32153,12 +33104,61 @@ func ParsePatchV0CityByCityNameAgentByDirByBaseResponse(rsp *http.Response) (*Pa } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -32186,12 +33186,26 @@ func ParseGetV0CityByCityNameAgentByDirByBaseOutputResponse(rsp *http.Response) } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -32245,12 +33259,54 @@ func ParsePostV0CityByCityNameAgentByDirByBaseByActionResponse(rsp *http.Respons } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -32278,12 +33334,26 @@ func ParseGetV0CityByCityNameAgentsResponse(rsp *http.Response) (*GetV0CityByCit } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -32311,12 +33381,75 @@ func ParseCreateAgentResponse(rsp *http.Response) (*CreateAgentResponse, error) } response.JSON201 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 504: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON504 = &dest } @@ -32344,12 +33477,47 @@ func ParseDeleteV0CityByCityNameBeadByIdResponse(rsp *http.Response) (*DeleteV0C } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -32377,12 +33545,33 @@ func ParseGetV0CityByCityNameBeadByIdResponse(rsp *http.Response) (*GetV0CityByC } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -32410,12 +33599,54 @@ func ParsePatchV0CityByCityNameBeadByIdResponse(rsp *http.Response) (*PatchV0Cit } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -32443,12 +33674,54 @@ func ParsePostV0CityByCityNameBeadByIdAssignResponse(rsp *http.Response) (*PostV } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -32476,12 +33749,47 @@ func ParsePostV0CityByCityNameBeadByIdCloseResponse(rsp *http.Response) (*PostV0 } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -32509,12 +33817,26 @@ func ParseGetV0CityByCityNameBeadByIdDepsResponse(rsp *http.Response) (*GetV0Cit } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -32542,12 +33864,47 @@ func ParsePostV0CityByCityNameBeadByIdReopenResponse(rsp *http.Response) (*PostV } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -32575,12 +33932,54 @@ func ParsePostV0CityByCityNameBeadByIdUpdateResponse(rsp *http.Response) (*PostV } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -32608,12 +34007,33 @@ func ParseGetV0CityByCityNameBeadsResponse(rsp *http.Response) (*GetV0CityByCity } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -32641,12 +34061,54 @@ func ParseCreateBeadResponse(rsp *http.Response) (*CreateBeadResponse, error) { } response.JSON201 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -32674,12 +34136,26 @@ func ParseGetV0CityByCityNameBeadsGraphByRootIdResponse(rsp *http.Response) (*Ge } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -32707,12 +34183,33 @@ func ParseGetV0CityByCityNameBeadsReadyResponse(rsp *http.Response) (*GetV0CityB } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -32740,12 +34237,26 @@ func ParseGetV0CityByCityNameConfigResponse(rsp *http.Response) (*GetV0CityByCit } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -32773,12 +34284,26 @@ func ParseGetV0CityByCityNameConfigDefaultsResponse(rsp *http.Response) (*GetV0C } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -32806,12 +34331,26 @@ func ParseGetV0CityByCityNameConfigExplainResponse(rsp *http.Response) (*GetV0Ci } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -32839,12 +34378,26 @@ func ParseGetV0CityByCityNameConfigValidateResponse(rsp *http.Response) (*GetV0C } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -32872,12 +34425,47 @@ func ParseDeleteV0CityByCityNameConvoyByIdResponse(rsp *http.Response) (*DeleteV } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -32905,12 +34493,33 @@ func ParseGetV0CityByCityNameConvoyByIdResponse(rsp *http.Response) (*GetV0CityB } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -32938,12 +34547,47 @@ func ParsePostV0CityByCityNameConvoyByIdAddResponse(rsp *http.Response) (*PostV0 } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -32971,17 +34615,45 @@ func ParseGetV0CityByCityNameConvoyByIdCheckResponse(rsp *http.Response) (*GetV0 } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest - } - - return response, nil -} + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest + + } + + return response, nil +} // ParsePostV0CityByCityNameConvoyByIdCloseResponse parses an HTTP response from a PostV0CityByCityNameConvoyByIdCloseWithResponse call func ParsePostV0CityByCityNameConvoyByIdCloseResponse(rsp *http.Response) (*PostV0CityByCityNameConvoyByIdCloseResponse, error) { @@ -33004,12 +34676,47 @@ func ParsePostV0CityByCityNameConvoyByIdCloseResponse(rsp *http.Response) (*Post } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -33037,12 +34744,47 @@ func ParsePostV0CityByCityNameConvoyByIdRemoveResponse(rsp *http.Response) (*Pos } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -33070,12 +34812,33 @@ func ParseGetV0CityByCityNameConvoysResponse(rsp *http.Response) (*GetV0CityByCi } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -33103,12 +34866,47 @@ func ParseCreateConvoyResponse(rsp *http.Response) (*CreateConvoyResponse, error } response.JSON201 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -33136,12 +34934,33 @@ func ParseGetV0CityByCityNameEventsResponse(rsp *http.Response) (*GetV0CityByCit } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -33169,12 +34988,47 @@ func ParseEmitEventResponse(rsp *http.Response) (*EmitEventResponse, error) { } response.JSON201 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -33202,12 +35056,47 @@ func ParseRotateEventsResponse(rsp *http.Response) (*RotateEventsResponse, error } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 405: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON405 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -33261,12 +35150,47 @@ func ParseDeleteV0CityByCityNameExtmsgAdaptersResponse(rsp *http.Response) (*Del } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -33294,12 +35218,33 @@ func ParseGetV0CityByCityNameExtmsgAdaptersResponse(rsp *http.Response) (*GetV0C } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -33327,12 +35272,47 @@ func ParseRegisterExtmsgAdapterResponse(rsp *http.Response) (*RegisterExtmsgAdap } response.JSON201 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -33360,12 +35340,61 @@ func ParsePostV0CityByCityNameExtmsgBindResponse(rsp *http.Response) (*PostV0Cit } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -33393,12 +35422,40 @@ func ParseGetV0CityByCityNameExtmsgBindingsResponse(rsp *http.Response) (*GetV0C } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -33426,12 +35483,33 @@ func ParseGetV0CityByCityNameExtmsgGroupsResponse(rsp *http.Response) (*GetV0Cit } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -33459,12 +35537,47 @@ func ParseEnsureExtmsgGroupResponse(rsp *http.Response) (*EnsureExtmsgGroupRespo } response.JSON201 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -33492,12 +35605,54 @@ func ParsePostV0CityByCityNameExtmsgInboundResponse(rsp *http.Response) (*PostV0 } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -33525,12 +35680,47 @@ func ParsePostV0CityByCityNameExtmsgOutboundResponse(rsp *http.Response) (*PostV } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -33558,12 +35748,47 @@ func ParseDeleteV0CityByCityNameExtmsgParticipantsResponse(rsp *http.Response) ( } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -33591,12 +35816,47 @@ func ParsePostV0CityByCityNameExtmsgParticipantsResponse(rsp *http.Response) (*P } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -33624,12 +35884,33 @@ func ParseGetV0CityByCityNameExtmsgTranscriptResponse(rsp *http.Response) (*GetV } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -33657,20 +35938,55 @@ func ParsePostV0CityByCityNameExtmsgTranscriptAckResponse(rsp *http.Response) (* } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest - } + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest - return response, nil -} + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest -// ParsePostV0CityByCityNameExtmsgUnbindResponse parses an HTTP response from a PostV0CityByCityNameExtmsgUnbindWithResponse call -func ParsePostV0CityByCityNameExtmsgUnbindResponse(rsp *http.Response) (*PostV0CityByCityNameExtmsgUnbindResponse, error) { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest + + } + + return response, nil +} + +// ParsePostV0CityByCityNameExtmsgUnbindResponse parses an HTTP response from a PostV0CityByCityNameExtmsgUnbindWithResponse call +func ParsePostV0CityByCityNameExtmsgUnbindResponse(rsp *http.Response) (*PostV0CityByCityNameExtmsgUnbindResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { @@ -33690,12 +36006,54 @@ func ParsePostV0CityByCityNameExtmsgUnbindResponse(rsp *http.Response) (*PostV0C } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -33723,12 +36081,40 @@ func ParseGetV0CityByCityNameFormulaByNameResponse(rsp *http.Response) (*GetV0Ci } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -33756,12 +36142,40 @@ func ParseGetV0CityByCityNameFormulasResponse(rsp *http.Response) (*GetV0CityByC } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -33789,12 +36203,40 @@ func ParseGetV0CityByCityNameFormulasFeedResponse(rsp *http.Response) (*GetV0Cit } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -33822,12 +36264,54 @@ func ParseDeleteV0CityByCityNameFormulasByNameResponse(rsp *http.Response) (*Del } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -33855,12 +36339,40 @@ func ParseGetV0CityByCityNameFormulasByNameResponse(rsp *http.Response) (*GetV0C } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -33888,12 +36400,61 @@ func ParsePutV0CityByCityNameFormulasByNameResponse(rsp *http.Response) (*PutV0C } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 413: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON413 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -33921,12 +36482,54 @@ func ParsePostV0CityByCityNameFormulasByNamePreviewResponse(rsp *http.Response) } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -33954,12 +36557,40 @@ func ParseGetV0CityByCityNameFormulasByNameRunsResponse(rsp *http.Response) (*Ge } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -33987,12 +36618,40 @@ func ParseGetV0CityByCityNameFormulasByNameSourceResponse(rsp *http.Response) (* } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -34020,12 +36679,47 @@ func ParsePostV0CityByCityNameFormulasByNameValidateResponse(rsp *http.Response) } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 413: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON413 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -34053,12 +36747,26 @@ func ParseGetV0CityByCityNameHealthResponse(rsp *http.Response) (*GetV0CityByCit } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -34086,12 +36794,40 @@ func ParseGetV0CityByCityNameMailResponse(rsp *http.Response) (*GetV0CityByCityN } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -34119,12 +36855,54 @@ func ParseSendMailResponse(rsp *http.Response) (*SendMailResponse, error) { } response.JSON201 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -34152,12 +36930,33 @@ func ParseGetV0CityByCityNameMailCountResponse(rsp *http.Response) (*GetV0CityBy } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -34185,12 +36984,33 @@ func ParseGetV0CityByCityNameMailThreadByIdResponse(rsp *http.Response) (*GetV0C } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -34218,12 +37038,40 @@ func ParseDeleteV0CityByCityNameMailByIdResponse(rsp *http.Response) (*DeleteV0C } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -34251,12 +37099,33 @@ func ParseGetV0CityByCityNameMailByIdResponse(rsp *http.Response) (*GetV0CityByC } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -34284,12 +37153,40 @@ func ParsePostV0CityByCityNameMailByIdArchiveResponse(rsp *http.Response) (*Post } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -34317,12 +37214,40 @@ func ParsePostV0CityByCityNameMailByIdMarkUnreadResponse(rsp *http.Response) (*P } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -34350,20 +37275,48 @@ func ParsePostV0CityByCityNameMailByIdReadResponse(rsp *http.Response) (*PostV0C } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest - } + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest - return response, nil -} + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest -// ParseReplyMailResponse parses an HTTP response from a ReplyMailWithResponse call -func ParseReplyMailResponse(rsp *http.Response) (*ReplyMailResponse, error) { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + } + + return response, nil +} + +// ParseReplyMailResponse parses an HTTP response from a ReplyMailWithResponse call +func ParseReplyMailResponse(rsp *http.Response) (*ReplyMailResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { @@ -34383,12 +37336,40 @@ func ParseReplyMailResponse(rsp *http.Response) (*ReplyMailResponse, error) { } response.JSON201 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -34416,12 +37397,54 @@ func ParseTriggerMaintenanceDoltGcResponse(rsp *http.Response) (*TriggerMaintena } response.JSON202 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -34449,12 +37472,33 @@ func ParseGetV0CityByCityNameMaintenanceStatusResponse(rsp *http.Response) (*Get } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -34482,12 +37526,33 @@ func ParseGetV0CityByCityNameOrderHistoryByBeadIdResponse(rsp *http.Response) (* } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -34515,12 +37580,33 @@ func ParseGetV0CityByCityNameOrderByNameResponse(rsp *http.Response) (*GetV0City } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -34548,12 +37634,61 @@ func ParsePostV0CityByCityNameOrderByNameDisableResponse(rsp *http.Response) (*P } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -34581,12 +37716,61 @@ func ParsePostV0CityByCityNameOrderByNameEnableResponse(rsp *http.Response) (*Po } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -34614,12 +37798,47 @@ func ParsePostV0CityByCityNameOrderByNameRunResponse(rsp *http.Response) (*PostV } response.JSON202 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -34647,12 +37866,26 @@ func ParseGetV0CityByCityNameOrdersResponse(rsp *http.Response) (*GetV0CityByCit } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -34680,12 +37913,26 @@ func ParseGetV0CityByCityNameOrdersCheckResponse(rsp *http.Response) (*GetV0City } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -34713,12 +37960,33 @@ func ParseGetV0CityByCityNameOrdersFeedResponse(rsp *http.Response) (*GetV0CityB } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -34746,12 +38014,40 @@ func ParseGetV0CityByCityNameOrdersHistoryResponse(rsp *http.Response) (*GetV0Ci } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -34779,12 +38075,33 @@ func ParseGetV0CityByCityNamePacksResponse(rsp *http.Response) (*GetV0CityByCity } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -34812,12 +38129,61 @@ func ParseAddPackResponse(rsp *http.Response) (*AddPackResponse, error) { } response.JSON201 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON502 = &dest } @@ -34845,12 +38211,47 @@ func ParseDeleteV0CityByCityNamePacksByNameResponse(rsp *http.Response) (*Delete } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -34878,12 +38279,54 @@ func ParseDeleteV0CityByCityNamePatchesAgentByBaseResponse(rsp *http.Response) ( } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -34911,12 +38354,26 @@ func ParseGetV0CityByCityNamePatchesAgentByBaseResponse(rsp *http.Response) (*Ge } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -34944,12 +38401,54 @@ func ParseDeleteV0CityByCityNamePatchesAgentByDirByBaseResponse(rsp *http.Respon } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -34977,12 +38476,26 @@ func ParseGetV0CityByCityNamePatchesAgentByDirByBaseResponse(rsp *http.Response) } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -35010,12 +38523,26 @@ func ParseGetV0CityByCityNamePatchesAgentsResponse(rsp *http.Response) (*GetV0Ci } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -35043,12 +38570,54 @@ func ParsePutV0CityByCityNamePatchesAgentsResponse(rsp *http.Response) (*PutV0Ci } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -35076,17 +38645,59 @@ func ParseDeleteV0CityByCityNamePatchesProviderByNameResponse(rsp *http.Response } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest - } + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest - return response, nil -} + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest + + } + + return response, nil +} // ParseGetV0CityByCityNamePatchesProviderByNameResponse parses an HTTP response from a GetV0CityByCityNamePatchesProviderByNameWithResponse call func ParseGetV0CityByCityNamePatchesProviderByNameResponse(rsp *http.Response) (*GetV0CityByCityNamePatchesProviderByNameResponse, error) { @@ -35109,12 +38720,26 @@ func ParseGetV0CityByCityNamePatchesProviderByNameResponse(rsp *http.Response) ( } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -35142,12 +38767,26 @@ func ParseGetV0CityByCityNamePatchesProvidersResponse(rsp *http.Response) (*GetV } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -35175,12 +38814,54 @@ func ParsePutV0CityByCityNamePatchesProvidersResponse(rsp *http.Response) (*PutV } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -35208,12 +38889,54 @@ func ParseDeleteV0CityByCityNamePatchesRigByNameResponse(rsp *http.Response) (*D } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -35241,12 +38964,26 @@ func ParseGetV0CityByCityNamePatchesRigByNameResponse(rsp *http.Response) (*GetV } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -35274,12 +39011,26 @@ func ParseGetV0CityByCityNamePatchesRigsResponse(rsp *http.Response) (*GetV0City } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -35307,12 +39058,54 @@ func ParsePutV0CityByCityNamePatchesRigsResponse(rsp *http.Response) (*PutV0City } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -35340,12 +39133,33 @@ func ParseGetV0CityByCityNamePendingResponse(rsp *http.Response) (*GetV0CityByCi } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -35373,12 +39187,33 @@ func ParseGetV0CityByCityNameProviderReadinessResponse(rsp *http.Response) (*Get } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -35406,12 +39241,61 @@ func ParseDeleteV0CityByCityNameProviderByNameResponse(rsp *http.Response) (*Del } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -35439,12 +39323,26 @@ func ParseGetV0CityByCityNameProviderByNameResponse(rsp *http.Response) (*GetV0C } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -35472,12 +39370,61 @@ func ParsePatchV0CityByCityNameProviderByNameResponse(rsp *http.Response) (*Patc } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -35505,12 +39452,26 @@ func ParseGetV0CityByCityNameProvidersResponse(rsp *http.Response) (*GetV0CityBy } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -35538,12 +39499,61 @@ func ParseCreateProviderResponse(rsp *http.Response) (*CreateProviderResponse, e } response.JSON201 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -35571,12 +39581,26 @@ func ParseGetV0CityByCityNameProvidersPublicResponse(rsp *http.Response) (*GetV0 } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -35604,12 +39628,33 @@ func ParseGetV0CityByCityNameReadinessResponse(rsp *http.Response) (*GetV0CityBy } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -35637,12 +39682,54 @@ func ParseDeleteV0CityByCityNameRigByNameResponse(rsp *http.Response) (*DeleteV0 } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -35670,12 +39757,26 @@ func ParseGetV0CityByCityNameRigByNameResponse(rsp *http.Response) (*GetV0CityBy } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -35703,12 +39804,54 @@ func ParsePatchV0CityByCityNameRigByNameResponse(rsp *http.Response) (*PatchV0Ci } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -35736,12 +39879,47 @@ func ParsePostV0CityByCityNameRigByNameByActionResponse(rsp *http.Response) (*Po } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest } @@ -35769,12 +39947,33 @@ func ParseGetV0CityByCityNameRigsResponse(rsp *http.Response) (*GetV0CityByCityN } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -35802,45 +40001,108 @@ func ParseCreateRigResponse(rsp *http.Response) (*CreateRigResponse, error) { } response.JSON201 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest - - } - - return response, nil -} - -// ParseGetV0CityByCityNameServiceByNameResponse parses an HTTP response from a GetV0CityByCityNameServiceByNameWithResponse call -func ParseGetV0CityByCityNameServiceByNameResponse(rsp *http.Response) (*GetV0CityByCityNameServiceByNameResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &GetV0CityByCityNameServiceByNameResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } + response.ApplicationproblemJSON400 = &dest - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest Status + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.JSON200 = &dest + response.ApplicationproblemJSON401 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest + + } + + return response, nil +} + +// ParseGetV0CityByCityNameServiceByNameResponse parses an HTTP response from a GetV0CityByCityNameServiceByNameWithResponse call +func ParseGetV0CityByCityNameServiceByNameResponse(rsp *http.Response) (*GetV0CityByCityNameServiceByNameResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetV0CityByCityNameServiceByNameResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Status + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -35868,12 +40130,40 @@ func ParsePostV0CityByCityNameServiceByNameRestartResponse(rsp *http.Response) ( } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -35901,12 +40191,26 @@ func ParseGetV0CityByCityNameServicesResponse(rsp *http.Response) (*GetV0CityByC } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -35934,12 +40238,40 @@ func ParseGetV0CityByCityNameSessionByIdResponse(rsp *http.Response) (*GetV0City } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -35967,12 +40299,61 @@ func ParsePatchV0CityByCityNameSessionByIdResponse(rsp *http.Response) (*PatchV0 } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -36000,12 +40381,40 @@ func ParseGetV0CityByCityNameSessionByIdAgentsResponse(rsp *http.Response) (*Get } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -36033,12 +40442,47 @@ func ParseGetV0CityByCityNameSessionByIdAgentsByAgentIdResponse(rsp *http.Respon } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -36066,12 +40510,54 @@ func ParsePostV0CityByCityNameSessionByIdCloseResponse(rsp *http.Response) (*Pos } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -36099,12 +40585,54 @@ func ParsePostV0CityByCityNameSessionByIdKillResponse(rsp *http.Response) (*Post } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -36132,12 +40660,47 @@ func ParseSendSessionMessageResponse(rsp *http.Response) (*SendSessionMessageRes } response.JSON202 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -36165,12 +40728,40 @@ func ParseGetV0CityByCityNameSessionByIdPendingResponse(rsp *http.Response) (*Ge } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -36198,12 +40789,68 @@ func ParsePostV0CityByCityNameSessionByIdPermissionModeResponse(rsp *http.Respon } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -36231,12 +40878,61 @@ func ParsePostV0CityByCityNameSessionByIdRenameResponse(rsp *http.Response) (*Po } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -36264,12 +40960,61 @@ func ParseRespondSessionResponse(rsp *http.Response) (*RespondSessionResponse, e } response.JSON202 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 501: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON501 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -36297,12 +41042,54 @@ func ParsePostV0CityByCityNameSessionByIdStopResponse(rsp *http.Response) (*Post } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -36356,12 +41143,47 @@ func ParseSubmitSessionResponse(rsp *http.Response) (*SubmitSessionResponse, err } response.JSON202 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -36389,12 +41211,54 @@ func ParsePostV0CityByCityNameSessionByIdSuspendResponse(rsp *http.Response) (*P } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -36422,12 +41286,40 @@ func ParseGetV0CityByCityNameSessionByIdTranscriptResponse(rsp *http.Response) ( } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -36455,12 +41347,61 @@ func ParsePostV0CityByCityNameSessionByIdWakeResponse(rsp *http.Response) (*Post } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -36488,12 +41429,33 @@ func ParseGetV0CityByCityNameSessionsResponse(rsp *http.Response) (*GetV0CityByC } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -36521,12 +41483,54 @@ func ParseCreateSessionResponse(rsp *http.Response) (*CreateSessionResponse, err } response.JSON202 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -36554,12 +41558,54 @@ func ParsePostV0CityByCityNameSlingResponse(rsp *http.Response) (*PostV0CityByCi } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -36587,12 +41633,33 @@ func ParseGetV0CityByCityNameStatusResponse(rsp *http.Response) (*GetV0CityByCit } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON503 = &dest } @@ -36653,12 +41720,47 @@ func ParseDeleteV0CityByCityNameWorkflowByWorkflowIdResponse(rsp *http.Response) } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } @@ -36686,12 +41788,33 @@ func ParseGetV0CityByCityNameWorkflowByWorkflowIdResponse(rsp *http.Response) (* } response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorModel if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.ApplicationproblemJSONDefault = &dest + response.ApplicationproblemJSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 422: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON422 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorModel + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSON500 = &dest } diff --git a/internal/api/handler_maintenance.go b/internal/api/handler_maintenance.go index 5f3a576353..09778eed55 100644 --- a/internal/api/handler_maintenance.go +++ b/internal/api/handler_maintenance.go @@ -6,7 +6,7 @@ import ( "errors" "time" - "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/supervisor" ) @@ -15,7 +15,7 @@ import ( // maintenance_disabled lets the CLI surface a targeted error instead of // collapsing it into the generic cache-not-live fallback bucket. func maintenanceDisabled() error { - return huma.Error503ServiceUnavailable("maintenance_disabled: [maintenance.dolt] enabled=false in city.toml") + return apierr.ServiceUnavailable.Msg("maintenance_disabled: [maintenance.dolt] enabled=false in city.toml") } // humaHandleMaintenanceStatus is the GET /v0/city/{city}/maintenance/status @@ -192,9 +192,9 @@ func maintenanceConflictFromError(err error) error { if encErr != nil { enc = []byte(`{"type":"maintenance-in-progress"}`) } - return huma.Error409Conflict("maintenance-in-progress: " + string(enc)) + return apierr.OperationInProgress.Msg("maintenance-in-progress: " + string(enc)) } - return huma.Error500InternalServerError(err.Error()) + return apierr.Internal.Msg(err.Error()) } // maintenanceRunBodyFromRun converts a supervisor.MaintenanceRun into the diff --git a/internal/api/handler_rigs_test.go b/internal/api/handler_rigs_test.go index f2ab35ccee..d6a2f7d14f 100644 --- a/internal/api/handler_rigs_test.go +++ b/internal/api/handler_rigs_test.go @@ -247,7 +247,24 @@ func TestRigActionUnknown(t *testing.T) { rec := httptest.NewRecorder() h.ServeHTTP(rec, newPostRequest(cityURL(state, "/rig/myrig/reboot"), nil)) - if rec.Code != http.StatusNotFound { - t.Fatalf("status = %d, want 404", rec.Code) + // RigActionInput.Action carries an enum:"suspend,resume,restart" schema, so + // Huma rejects an unknown action at request validation with the typed + // validation-failed contract (mirroring the agent-action surface) rather than + // the pre-conversion legacy bare-404 body with empty code/type. + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("status = %d, want 422; body: %s", rec.Code, rec.Body.String()) + } + var pd struct { + Type string `json:"type"` + Code string `json:"code"` + } + if err := json.NewDecoder(rec.Body).Decode(&pd); err != nil { + t.Fatalf("decode 422 body: %v", err) + } + if pd.Code != "validation-failed" { + t.Errorf("code = %q, want validation-failed", pd.Code) + } + if pd.Type != "urn:gascity:error:validation-failed" { + t.Errorf("type = %q, want urn:gascity:error:validation-failed", pd.Type) } } diff --git a/internal/api/handler_sling_test.go b/internal/api/handler_sling_test.go index 65ca498fc2..fc55b84392 100644 --- a/internal/api/handler_sling_test.go +++ b/internal/api/handler_sling_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/gastownhall/gascity/internal/agentutil" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/formula" @@ -123,7 +124,7 @@ func TestSlingRefusesCityStoreBeadToRigTarget(t *testing.T) { if err := json.NewDecoder(rec.Body).Decode(&problem); err != nil { t.Fatalf("decode: %v", err) } - if problem.Type != slingCrossStoreRouteProblemType { + if problem.Type != "urn:gascity:error:sling-cross-store-route" { t.Fatalf("type = %q, want cross-store discriminator", problem.Type) } for _, want := range []string{"refusing cross-store route", "city:test-city", "myrig/worker", "rig:myrig"} { @@ -415,7 +416,11 @@ func TestSlingProblemTypesDocumentedInOpenAPI(t *testing.T) { if err != nil { t.Fatalf("marshal components: %v", err) } - for _, want := range []string{slingMissingBeadProblemType, slingCrossRigProblemType, slingCrossStoreRouteProblemType} { + for _, want := range []string{ + "urn:gascity:error:sling-missing-bead", + "urn:gascity:error:sling-cross-rig", + "urn:gascity:error:sling-cross-store-route", + } { if !bytes.Contains(components, []byte(want)) { t.Fatalf("OpenAPI components missing problem type %q", want) } @@ -444,9 +449,9 @@ func TestDocumentProblemTypesIsIdempotent(t *testing.T) { counts[s]++ } } - for _, problemType := range documentedProblemTypes { - if counts[problemType] != 1 { - t.Fatalf("example count for %q = %d, want 1", problemType, counts[problemType]) + for _, pt := range apierr.Registered() { + if counts[pt.URN()] != 1 { + t.Fatalf("example count for %q = %d, want 1", pt.URN(), counts[pt.URN()]) } } } diff --git a/internal/api/handler_webhook.go b/internal/api/handler_webhook.go index 49091282fa..a379adfe5f 100644 --- a/internal/api/handler_webhook.go +++ b/internal/api/handler_webhook.go @@ -10,7 +10,7 @@ import ( "net/http" "strings" - "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/orderdispatch" "github.com/gastownhall/gascity/internal/orders" @@ -636,18 +636,18 @@ type OrderRunOutput struct { func (s *Server) humaHandleOrderRun(ctx context.Context, input *OrderRunInput) (*OrderRunOutput, error) { order, ok := resolveWebhookOrder(s.state, input.Name) if !ok { - return nil, huma.Error404NotFound("not_found: order not found: " + input.Name) + return nil, apierr.OrderNotFound.Msg("not_found: order not found: " + input.Name) } // Refuse non-webhook-trigger orders up front for a clear 422 (the sink also // enforces this — defense in depth). if strings.TrimSpace(order.Trigger) != "webhook" { - return nil, huma.Error422UnprocessableEntity(fmt.Sprintf( + return nil, apierr.WebhookRejected.Msg(fmt.Sprintf( "order %q has trigger %q; the run endpoint fires only trigger=\"webhook\" orders", order.ScopedName(), order.Trigger)) } dispatcher := webhookDispatcherFor(s.state) if dispatcher == nil { - return nil, huma.Error503ServiceUnavailable("webhook dispatch is not available for this city") + return nil, apierr.ServiceUnavailable.Msg("webhook dispatch is not available for this city") } result, err := webhooksink.Route(context.WithoutCancel(ctx), webhooksink.Deps{ Dispatcher: dispatcher, @@ -659,10 +659,10 @@ func (s *Server) humaHandleOrderRun(ctx context.Context, input *OrderRunInput) ( Vars: input.Body.Vars, }) if err != nil { - return nil, huma.Error503ServiceUnavailable("dispatch failed: " + err.Error()) + return nil, apierr.ServiceUnavailable.Msg("dispatch failed: " + err.Error()) } if !result.Dispatched { - return nil, huma.Error422UnprocessableEntity("rejected: " + result.Reason) + return nil, apierr.WebhookRejected.Msg("rejected: " + result.Reason) } out := &OrderRunOutput{Status: http.StatusAccepted} out.Body.Status = "dispatched" diff --git a/internal/api/huma_handlers_agents.go b/internal/api/huma_handlers_agents.go index 0cd146fa87..1566500d34 100644 --- a/internal/api/huma_handlers_agents.go +++ b/internal/api/huma_handlers_agents.go @@ -10,6 +10,7 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/sse" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/config" ) @@ -171,7 +172,7 @@ func (s *Server) humaHandleAgentQualified(_ context.Context, input *AgentGetQual // dispatching here. func (s *Server) agentByName(name string) (*IndexOutput[agentResponse], error) { if name == "" { - return nil, huma.Error400BadRequest("agent name required") + return nil, apierr.InvalidRequest.Msg("agent name required") } cfg := s.state.Config() @@ -180,7 +181,7 @@ func (s *Server) agentByName(name string) (*IndexOutput[agentResponse], error) { agentCfg, ok := findAgent(cfg, name) if !ok { - return nil, huma.Error404NotFound("agent " + name + " not found") + return nil, apierr.AgentNotFound.Msg("agent " + name + " not found") } sessionName := agentSessionName(cityName, name, cfg.Workspace.SessionTemplate) @@ -300,11 +301,11 @@ func (s *Server) humaHandleAgentCreate(ctx context.Context, input *AgentCreateIn func agentVisibilityWaitHTTPError(err error) error { switch { case errors.Is(err, context.Canceled): - return agentVisibilityRetryableError(huma.Error503ServiceUnavailable("agent was created, but visibility confirmation was canceled")) + return agentVisibilityRetryableError(apierr.ServiceUnavailable.Msg("agent was created, but visibility confirmation was canceled")) case errors.Is(err, context.DeadlineExceeded): - return agentVisibilityRetryableError(huma.Error504GatewayTimeout("agent was created, but visibility was not confirmed before timeout")) + return agentVisibilityRetryableError(apierr.GatewayTimeout.Msg("agent was created, but visibility was not confirmed before timeout")) default: - return huma.Error500InternalServerError("agent was created, but visibility confirmation failed") + return apierr.Internal.Msg("agent was created, but visibility confirmation failed") } } @@ -382,7 +383,7 @@ func (s *Server) agentActionByName(name, action string) (*OKResponse, error) { } cfg := s.state.Config() if _, ok := findAgent(cfg, name); !ok { - return nil, huma.Error404NotFound("agent " + name + " not found") + return nil, apierr.AgentNotFound.Msg("agent " + name + " not found") } var err error switch action { @@ -391,7 +392,7 @@ func (s *Server) agentActionByName(name, action string) (*OKResponse, error) { case "resume": err = sm.ResumeAgent(name) default: - return nil, huma.Error400BadRequest("unknown agent action: " + action) + return nil, apierr.InvalidRequest.Msg("unknown agent action: " + action) } if err != nil { return nil, mutationError(err) @@ -434,12 +435,12 @@ func (s *Server) agentOutputByName(name string, tail int, provided bool, before cfg := s.state.Config() agentCfg, ok := findAgent(cfg, name) if !ok { - return nil, huma.Error404NotFound("agent " + name + " not found") + return nil, apierr.AgentNotFound.Msg("agent " + name + " not found") } resp, err := s.trySessionLogOutputHuma(name, agentCfg, tail, provided, before) if err != nil { - return nil, huma.Error500InternalServerError("reading session log: " + err.Error()) + return nil, apierr.Internal.Msg("reading session log: " + err.Error()) } if resp != nil { return &struct { @@ -451,12 +452,12 @@ func (s *Server) agentOutputByName(name string, tail int, provided bool, before sp := s.state.SessionProvider() sessionName := agentSessionName(s.state.CityName(), name, cfg.Workspace.SessionTemplate) if !sp.IsRunning(sessionName) { - return nil, huma.Error404NotFound("agent " + name + " not running") + return nil, apierr.AgentNotFound.Msg("agent " + name + " not running") } output, err := sp.Peek(sessionName, 100) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } turns := []outputTurn{} @@ -493,13 +494,13 @@ func (s *Server) resolveAgentStream(name string) (*agentStreamState, error) { cfg := s.state.Config() agentCfg, ok := findAgent(cfg, name) if !ok { - return nil, huma.Error404NotFound("agent " + name + " not found") + return nil, apierr.AgentNotFound.Msg("agent " + name + " not found") } workDir := s.resolveAgentWorkDir(agentCfg, name) transcriptState, err := s.resolveAgentTranscript(name, agentCfg) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } provider := transcriptState.provider logPath := transcriptState.path @@ -509,7 +510,7 @@ func (s *Server) resolveAgentStream(name string) (*agentStreamState, error) { running := sp.IsRunning(sessionName) if logPath == "" && !running { - return nil, huma.Error404NotFound("agent " + name + " not running") + return nil, apierr.AgentNotFound.Msg("agent " + name + " not running") } return &agentStreamState{ name: name, diff --git a/internal/api/huma_handlers_beads.go b/internal/api/huma_handlers_beads.go index d73107948f..0bbcc3f585 100644 --- a/internal/api/huma_handlers_beads.go +++ b/internal/api/huma_handlers_beads.go @@ -5,7 +5,7 @@ import ( "errors" "time" - "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/beads" ) @@ -345,7 +345,10 @@ func (s *Server) humaHandleBeadReady(ctx context.Context, input *BeadReadyInput) func (s *Server) humaHandleBeadGraph(_ context.Context, input *BeadGraphInput) (*IndexOutput[BeadGraphResponse], error) { rootID := input.RootID if rootID == "" { - return nil, huma.Error400BadRequest("rootID is required") + // Defensive: the {rootID} path segment is required, so the router never + // dispatches here with an empty id. Unreachable in practice, hence the op + // does not declare a 400 in its error contract. + return nil, apierr.InvalidRequest.Msg("rootID is required") } var root beads.Bead @@ -356,19 +359,19 @@ func (s *Server) humaHandleBeadGraph(_ context.Context, input *BeadGraphInput) ( if errors.Is(err, beads.ErrNotFound) { continue } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } root = b foundStore = store break } if foundStore == nil { - return nil, huma.Error404NotFound("bead " + rootID + " not found") + return nil, apierr.BeadNotFound.Msg("bead " + rootID + " not found") } graphBeads, parentEdges, err := collectBeadGraph(foundStore, root) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } beadIndex := make(map[string]beads.Bead, len(graphBeads)) for _, b := range graphBeads { @@ -377,7 +380,7 @@ func (s *Server) humaHandleBeadGraph(_ context.Context, input *BeadGraphInput) ( deps, depPartial := collectWorkflowDeps(foundStore, beadIndex) if depPartial { - return nil, huma.Error500InternalServerError("listing bead graph dependencies failed") + return nil, apierr.Internal.Msg("listing bead graph dependencies failed") } deps = mergeWorkflowDeps(deps, parentEdges) @@ -406,7 +409,7 @@ func (s *Server) humaHandleBeadGet(_ context.Context, input *BeadGetInput) (*Ind if errors.Is(err, beads.ErrNotFound) { continue } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } return &IndexOutput[beads.Bead]{ Index: s.latestIndex(), @@ -414,7 +417,7 @@ func (s *Server) humaHandleBeadGet(_ context.Context, input *BeadGetInput) (*Ind Body: b, }, nil } - return nil, huma.Error404NotFound("bead " + id + " not found") + return nil, apierr.BeadNotFound.Msg("bead " + id + " not found") } // humaHandleBeadDeps is the Huma-typed handler for GET /v0/bead/{id}/deps. @@ -426,14 +429,14 @@ func (s *Server) humaHandleBeadDeps(_ context.Context, input *BeadDepsInput) (*I if errors.Is(err, beads.ErrNotFound) { continue } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } children, err := store.List(beads.ListQuery{ ParentID: id, Sort: beads.SortCreatedAsc, }) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } children = appendMetadataAttachedChildren(store, parent, children) if children == nil { @@ -444,7 +447,7 @@ func (s *Server) humaHandleBeadDeps(_ context.Context, input *BeadDepsInput) (*I Body: BeadDepsResponse{Children: children}, }, nil } - return nil, huma.Error404NotFound("bead " + id + " not found") + return nil, apierr.BeadNotFound.Msg("bead " + id + " not found") } // BeadDepsResponse is the response shape for GET /v0/bead/{id}/deps. @@ -464,10 +467,10 @@ func (s *Server) humaHandleBeadCreate(ctx context.Context, input *BeadCreateInpu existing, found := s.idem.reserve(idemKey, bodyHash) if found { if existing.bodyHash != bodyHash { - return nil, huma.Error422UnprocessableEntity("idempotency_mismatch: Idempotency-Key reused with different request body") + return nil, apierr.IdempotencyMismatch.Msg("idempotency_mismatch: Idempotency-Key reused with different request body") } if existing.pending { - return nil, huma.Error409Conflict("in_flight: request with this Idempotency-Key is already in progress") + return nil, apierr.IdempotencyInFlight.Msg("in_flight: request with this Idempotency-Key is already in progress") } // Replay cached typed response (Fix 3l). if b, ok := replayAs[beads.Bead](existing); ok { @@ -482,12 +485,12 @@ func (s *Server) humaHandleBeadCreate(ctx context.Context, input *BeadCreateInpu store := s.findStore(input.Body.Rig) if store == nil { s.idem.unreserve(idemKey) - return nil, huma.Error400BadRequest("rig is required when multiple rigs are configured") + return nil, apierr.InvalidRequest.Msg("rig is required when multiple rigs are configured") } assignee, err := s.normalizeRawBeadAssignee(ctx, input.Body.Assignee) if err != nil { s.idem.unreserve(idemKey) - return nil, huma.Error400BadRequest(err.Error()) + return nil, apierr.InvalidRequest.Msg(err.Error()) } b, err := store.Create(beads.Bead{ @@ -503,7 +506,7 @@ func (s *Server) humaHandleBeadCreate(ctx context.Context, input *BeadCreateInpu }) if err != nil { s.idem.unreserve(idemKey) - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } // Some stores return a minimal create envelope and require a follow-up @@ -527,19 +530,19 @@ func (s *Server) humaHandleBeadClose(_ context.Context, input *BeadCloseInput) ( if errors.Is(err, beads.ErrNotFound) { continue } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } if err := store.Close(id); err != nil { if errors.Is(err, beads.ErrNotFound) { - return nil, huma.Error409Conflict("conflict: bead " + id + " was deleted concurrently") + return nil, apierr.ConflictConcurrentDelete.Msg("conflict: bead " + id + " was deleted concurrently") } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } resp := &OKResponse{} resp.Body.Status = "closed" return resp, nil } - return nil, huma.Error404NotFound("bead " + id + " not found") + return nil, apierr.BeadNotFound.Msg("bead " + id + " not found") } // humaHandleBeadReopen is the Huma-typed handler for POST /v0/bead/{id}/reopen. @@ -552,19 +555,19 @@ func (s *Server) humaHandleBeadReopen(_ context.Context, input *BeadReopenInput) if errors.Is(err, beads.ErrNotFound) { continue } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } if b.Status != "closed" { - return nil, huma.Error409Conflict("conflict: bead " + id + " is not closed (status: " + b.Status + ")") + return nil, apierr.ConflictWrongState.Msg("conflict: bead " + id + " is not closed (status: " + b.Status + ")") } if err := store.Reopen(id); err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } resp := &OKResponse{} resp.Body.Status = "reopened" return resp, nil } - return nil, huma.Error404NotFound("bead " + id + " not found") + return nil, apierr.BeadNotFound.Msg("bead " + id + " not found") } // humaHandleBeadAssign is the Huma-typed handler for POST /v0/bead/{id}/assign. @@ -575,11 +578,11 @@ func (s *Server) humaHandleBeadAssign(ctx context.Context, input *BeadAssignInpu if errors.Is(err, beads.ErrNotFound) { continue } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } assignee, err := s.normalizeRawBeadAssignee(ctx, input.Body.Assignee) if err != nil { - return nil, huma.Error400BadRequest(err.Error()) + return nil, apierr.InvalidRequest.Msg(err.Error()) } // Once Get succeeded in this store, treat Update-ErrNotFound as a // concurrent-delete race rather than "try the next store" — the bead @@ -587,16 +590,16 @@ func (s *Server) humaHandleBeadAssign(ctx context.Context, input *BeadAssignInpu // that happens to share the ID prefix. if err := store.Update(id, beads.UpdateOpts{Assignee: &assignee}); err != nil { if errors.Is(err, beads.ErrNotFound) { - return nil, huma.Error409Conflict("conflict: bead " + id + " was deleted concurrently") + return nil, apierr.ConflictConcurrentDelete.Msg("conflict: bead " + id + " was deleted concurrently") } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } return &IndexOutput[map[string]string]{ Index: s.latestIndex(), Body: map[string]string{"status": "assigned", "assignee": assignee}, }, nil } - return nil, huma.Error404NotFound("bead " + id + " not found") + return nil, apierr.BeadNotFound.Msg("bead " + id + " not found") } // humaHandleBeadUpdate is the Huma-typed handler for POST /v0/bead/{id}/update @@ -638,12 +641,12 @@ func (s *Server) humaHandleBeadUpdate(ctx context.Context, input *BeadUpdateInpu if errors.Is(err, beads.ErrNotFound) { continue } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } if body.Assignee != nil { assignee, err := s.normalizeRawBeadAssignee(ctx, *body.Assignee) if err != nil { - return nil, huma.Error400BadRequest(err.Error()) + return nil, apierr.InvalidRequest.Msg(err.Error()) } opts.Assignee = &assignee } @@ -657,17 +660,17 @@ func (s *Server) humaHandleBeadUpdate(ctx context.Context, input *BeadUpdateInpu // the mutation to a different store that happens to share the ID. if err := store.Update(id, opts); err != nil { if errors.Is(err, beads.ErrNotFound) { - return nil, huma.Error409Conflict("conflict: bead " + id + " was deleted concurrently") + return nil, apierr.ConflictConcurrentDelete.Msg("conflict: bead " + id + " was deleted concurrently") } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } if opts.ParentID != nil && current.ParentID != *opts.ParentID && waitStatus != "closed" { if waiter, ok := store.(beads.ParentProjectionWaiter); ok { if err := waiter.WaitForParentProjection(ctx, id, current.ParentID, *opts.ParentID); err != nil { if errors.Is(err, beads.ErrParentProjectionSuperseded) { - return nil, huma.Error409Conflict("conflict: bead " + id + " was reparented concurrently") + return nil, apierr.ConflictConcurrentModify.Msg("conflict: bead " + id + " was reparented concurrently") } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } } } @@ -675,7 +678,7 @@ func (s *Server) humaHandleBeadUpdate(ctx context.Context, input *BeadUpdateInpu resp.Body.Status = "updated" return resp, nil } - return nil, huma.Error404NotFound("bead " + id + " not found") + return nil, apierr.BeadNotFound.Msg("bead " + id + " not found") } // humaHandleBeadDelete is the Huma-typed handler for DELETE /v0/bead/{id}. @@ -689,17 +692,17 @@ func (s *Server) humaHandleBeadDelete(_ context.Context, input *BeadDeleteInput) if errors.Is(err, beads.ErrNotFound) { continue } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } if err := store.Close(id); err != nil { if errors.Is(err, beads.ErrNotFound) { - return nil, huma.Error409Conflict("conflict: bead " + id + " was deleted concurrently") + return nil, apierr.ConflictConcurrentDelete.Msg("conflict: bead " + id + " was deleted concurrently") } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } resp := &OKResponse{} resp.Body.Status = "closed" return resp, nil } - return nil, huma.Error404NotFound("bead " + id + " not found") + return nil, apierr.BeadNotFound.Msg("bead " + id + " not found") } diff --git a/internal/api/huma_handlers_city.go b/internal/api/huma_handlers_city.go index 98c1fc87b5..e32c96d53d 100644 --- a/internal/api/huma_handlers_city.go +++ b/internal/api/huma_handlers_city.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/fsys" "github.com/gastownhall/gascity/internal/suspensionstate" ) @@ -35,7 +35,7 @@ func (s *Server) humaHandleCityPatch(_ context.Context, input *CityPatchInput) ( } if input.Body.Suspended == nil { - return nil, huma.Error400BadRequest("no fields to update") + return nil, apierr.InvalidRequest.Msg("no fields to update") } var err error @@ -62,12 +62,12 @@ func (s *Server) humaHandleProviderReadiness(ctx context.Context, input *Provide supportedProviderReadiness, ) if err != nil { - return nil, huma.Error400BadRequest(err.Error()) + return nil, apierr.InvalidRequest.Msg(err.Error()) } resp, err := buildReadinessResponse(ctx, providers, input.Fresh) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } providerResp := providerReadinessResponse{ @@ -94,12 +94,12 @@ func (s *Server) humaHandleReadiness(ctx context.Context, input *ReadinessInput) supportedReadiness, ) if err != nil { - return nil, huma.Error400BadRequest(err.Error()) + return nil, apierr.InvalidRequest.Msg(err.Error()) } resp, err := buildReadinessResponse(ctx, items, input.Fresh) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } return &ReadinessOutput{Body: resp}, nil diff --git a/internal/api/huma_handlers_convoys.go b/internal/api/huma_handlers_convoys.go index 45b407277c..241ba64509 100644 --- a/internal/api/huma_handlers_convoys.go +++ b/internal/api/huma_handlers_convoys.go @@ -6,7 +6,7 @@ import ( "log" "strings" - "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/beadmeta" "github.com/gastownhall/gascity/internal/beads" convoycore "github.com/gastownhall/gascity/internal/convoy" @@ -154,9 +154,9 @@ func (s *Server) humaHandleConvoyGet(_ context.Context, input *ConvoyGetInput) ( snapshot, err := s.buildWorkflowSnapshot(id, "", "", index) if err != nil { if errors.Is(err, errWorkflowNotFound) { - return nil, huma.Error404NotFound("workflow " + id + " not found") + return nil, apierr.WorkflowNotFound.Msg("workflow " + id + " not found") } - return nil, huma.Error500InternalServerError("workflow snapshot failed") + return nil, apierr.Internal.Msg("workflow snapshot failed") } return &IndexOutput[convoyGetResponse]{ Index: index, @@ -173,15 +173,15 @@ func (s *Server) humaHandleConvoyGet(_ context.Context, input *ConvoyGetInput) ( if errors.Is(err, beads.ErrNotFound) { continue } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } if b.Type != "convoy" { - return nil, huma.Error404NotFound("bead " + id + " is not a convoy") + return nil, apierr.ConvoyNotFound.Msg("bead " + id + " is not a convoy") } children, err := convoycore.Members(store, id, true) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } if children == nil { children = []beads.Bead{} @@ -205,7 +205,7 @@ func (s *Server) humaHandleConvoyGet(_ context.Context, input *ConvoyGetInput) ( }, }, nil } - return nil, huma.Error404NotFound("convoy " + id + " not found") + return nil, apierr.ConvoyNotFound.Msg("convoy " + id + " not found") } // humaHandleConvoyCreate is the Huma-typed handler for POST /v0/convoys. @@ -213,7 +213,7 @@ func (s *Server) humaHandleConvoyGet(_ context.Context, input *ConvoyGetInput) ( func (s *Server) humaHandleConvoyCreate(_ context.Context, input *ConvoyCreateInput) (*IndexOutput[beads.Bead], error) { store := s.findStore(input.Body.Rig) if store == nil { - return nil, huma.Error400BadRequest("rig is required when multiple rigs are configured") + return nil, apierr.InvalidRequest.Msg("rig is required when multiple rigs are configured") } // Pre-validate all items exist before creating the convoy. @@ -228,7 +228,7 @@ func (s *Server) humaHandleConvoyCreate(_ context.Context, input *ConvoyCreateIn Type: "convoy", }) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } // Link child items to convoy one at a time. On first failure, roll @@ -240,7 +240,7 @@ func (s *Server) humaHandleConvoyCreate(_ context.Context, input *ConvoyCreateIn if delErr := store.Delete(convoy.ID); delErr != nil { log.Printf("gc api: convoy create rollback: delete %s after link failure: %v", convoy.ID, delErr) } - return nil, huma.Error500InternalServerError("failed to link item " + itemID + ": " + err.Error()) + return nil, apierr.Internal.Msg("failed to link item " + itemID + ": " + err.Error()) } applied = append(applied, itemID) } @@ -264,10 +264,10 @@ func (s *Server) humaHandleConvoyAdd(_ context.Context, input *ConvoyAddInput) ( if errors.Is(err, beads.ErrNotFound) { continue } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } if b.Type != "convoy" { - return nil, huma.Error400BadRequest("bead " + id + " is not a convoy") + return nil, apierr.InvalidRequest.Msg("bead " + id + " is not a convoy") } // Pre-validate all items exist before linking. for _, itemID := range input.Body.Items { @@ -279,7 +279,7 @@ func (s *Server) humaHandleConvoyAdd(_ context.Context, input *ConvoyAddInput) ( for _, itemID := range input.Body.Items { if err := convoycore.TrackItem(store, id, itemID); err != nil { rollbackConvoyTracks(store, id, applied, "convoy.add") - return nil, huma.Error500InternalServerError("failed to link item " + itemID + ": " + err.Error()) + return nil, apierr.Internal.Msg("failed to link item " + itemID + ": " + err.Error()) } applied = append(applied, itemID) } @@ -287,7 +287,7 @@ func (s *Server) humaHandleConvoyAdd(_ context.Context, input *ConvoyAddInput) ( resp.Body.Status = "updated" return resp, nil } - return nil, huma.Error404NotFound("convoy " + id + " not found") + return nil, apierr.ConvoyNotFound.Msg("convoy " + id + " not found") } // humaHandleConvoyRemove is the Huma-typed handler for POST /v0/convoy/{id}/remove. @@ -301,10 +301,10 @@ func (s *Server) humaHandleConvoyRemove(_ context.Context, input *ConvoyRemoveIn if errors.Is(err, beads.ErrNotFound) { continue } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } if b.Type != "convoy" { - return nil, huma.Error400BadRequest("bead " + id + " is not a convoy") + return nil, apierr.InvalidRequest.Msg("bead " + id + " is not a convoy") } // Pre-validate all items exist and belong to this convoy via either // legacy parent-child membership or the current tracks dependency. @@ -313,16 +313,16 @@ func (s *Server) humaHandleConvoyRemove(_ context.Context, input *ConvoyRemoveIn item, gerr := store.Get(itemID) if gerr != nil { if errors.Is(gerr, beads.ErrNotFound) { - return nil, huma.Error404NotFound("item " + itemID + " not found") + return nil, apierr.BeadNotFound.Msg("item " + itemID + " not found") } - return nil, huma.Error500InternalServerError(gerr.Error()) + return nil, apierr.Internal.Msg(gerr.Error()) } hadTrack, terr := convoycore.HasTrack(store, id, itemID) if terr != nil { - return nil, huma.Error500InternalServerError(terr.Error()) + return nil, apierr.Internal.Msg(terr.Error()) } if item.ParentID != id && !hadTrack { - return nil, huma.Error400BadRequest("item " + itemID + " does not belong to convoy " + id) + return nil, apierr.InvalidRequest.Msg("item " + itemID + " does not belong to convoy " + id) } snapshots[itemID] = convoyMembershipSnapshot{ ParentID: item.ParentID, @@ -337,7 +337,7 @@ func (s *Server) humaHandleConvoyRemove(_ context.Context, input *ConvoyRemoveIn if snapshot.HadTrack { if err := convoycore.UntrackItem(store, id, itemID); err != nil { rollbackConvoyMembershipRemoval(store, id, applied, snapshots, "convoy.remove") - return nil, huma.Error500InternalServerError("failed to unlink item " + itemID + ": " + err.Error()) + return nil, apierr.Internal.Msg("failed to unlink item " + itemID + ": " + err.Error()) } } if snapshot.ParentID == id { @@ -348,7 +348,7 @@ func (s *Server) humaHandleConvoyRemove(_ context.Context, input *ConvoyRemoveIn } } rollbackConvoyMembershipRemoval(store, id, applied, snapshots, "convoy.remove") - return nil, huma.Error500InternalServerError("failed to unlink item " + itemID + ": " + err.Error()) + return nil, apierr.Internal.Msg("failed to unlink item " + itemID + ": " + err.Error()) } } applied = append(applied, itemID) @@ -357,7 +357,7 @@ func (s *Server) humaHandleConvoyRemove(_ context.Context, input *ConvoyRemoveIn resp.Body.Status = "updated" return resp, nil } - return nil, huma.Error404NotFound("convoy " + id + " not found") + return nil, apierr.ConvoyNotFound.Msg("convoy " + id + " not found") } func rollbackConvoyTracks(store beads.Store, convoyID string, applied []string, op string) { @@ -410,15 +410,15 @@ func (s *Server) humaHandleConvoyCheck(_ context.Context, input *ConvoyCheckInpu if errors.Is(err, beads.ErrNotFound) { continue } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } if b.Type != "convoy" { - return nil, huma.Error400BadRequest("bead " + id + " is not a convoy") + return nil, apierr.InvalidRequest.Msg("bead " + id + " is not a convoy") } children, err := convoycore.Members(store, id, true) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } total := len(children) @@ -441,7 +441,7 @@ func (s *Server) humaHandleConvoyCheck(_ context.Context, input *ConvoyCheckInpu }, }, nil } - return nil, huma.Error404NotFound("convoy " + id + " not found") + return nil, apierr.ConvoyNotFound.Msg("convoy " + id + " not found") } // humaHandleConvoyClose is the Huma-typed handler for POST /v0/convoy/{id}/close. @@ -456,19 +456,19 @@ func (s *Server) humaHandleConvoyClose(_ context.Context, input *ConvoyCloseInpu if errors.Is(err, beads.ErrNotFound) { continue } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } if b.Type != "convoy" { - return nil, huma.Error400BadRequest("bead " + id + " is not a convoy") + return nil, apierr.InvalidRequest.Msg("bead " + id + " is not a convoy") } if err := store.Close(id); err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } resp := &OKResponse{} resp.Body.Status = "closed" return resp, nil } - return nil, huma.Error404NotFound("convoy " + id + " not found") + return nil, apierr.ConvoyNotFound.Msg("convoy " + id + " not found") } // humaHandleConvoyDelete is the Huma-typed handler for DELETE /v0/convoy/{id}. @@ -489,19 +489,19 @@ func (s *Server) humaHandleConvoyDelete(_ context.Context, input *ConvoyDeleteIn if errors.Is(err, beads.ErrNotFound) { continue } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } if b.Type != "convoy" { - return nil, huma.Error400BadRequest("bead " + id + " is not a convoy") + return nil, apierr.InvalidRequest.Msg("bead " + id + " is not a convoy") } if err := store.Close(id); err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } resp := &OKResponse{} resp.Body.Status = "closed" return resp, nil } - return nil, huma.Error404NotFound("convoy " + id + " not found") + return nil, apierr.ConvoyNotFound.Msg("convoy " + id + " not found") } // humaDeleteWorkflow handles workflow convoy deletion through the Huma handler. @@ -576,7 +576,7 @@ func (s *Server) humaDeleteWorkflow(workflowID string) (*OKResponse, error) { } if !found { - return nil, huma.Error404NotFound("workflow " + workflowID + " not found") + return nil, apierr.WorkflowNotFound.Msg("workflow " + workflowID + " not found") } resp := &OKResponse{} @@ -584,12 +584,13 @@ func (s *Server) humaDeleteWorkflow(workflowID string) (*OKResponse, error) { return resp, nil } -// storeError converts a bead store error into the appropriate Huma error. +// storeError converts a bead store error into an apierr problem. It runs during +// convoy create/add item pre-validation, so a not-found is a missing member bead. func storeError(err error) error { if errors.Is(err, beads.ErrNotFound) { - return huma.Error404NotFound(err.Error()) + return apierr.BeadNotFound.Msg(err.Error()) } - return huma.Error500InternalServerError(err.Error()) + return apierr.Internal.Msg(err.Error()) } // humaHandleWorkflowGet is the Huma-typed handler for GET /v0/workflow/{workflow_id}. @@ -597,21 +598,21 @@ func storeError(err error) error { func (s *Server) humaHandleWorkflowGet(_ context.Context, input *WorkflowGetInput) (*IndexOutput[workflowSnapshotResponse], error) { workflowID := strings.TrimSpace(input.WorkflowID) if workflowID == "" { - return nil, huma.Error400BadRequest("convoy id is required") + return nil, apierr.InvalidRequest.Msg("convoy id is required") } scopeKind, scopeRef, scopeErr := parseOptionalWorkflowRequestScope(input.ScopeKind, input.ScopeRef) if scopeErr != "" { - return nil, huma.Error400BadRequest(scopeErr) + return nil, apierr.InvalidRequest.Msg(scopeErr) } index := s.latestIndex() snapshot, err := s.buildWorkflowSnapshot(workflowID, scopeKind, scopeRef, index) if err != nil { if errors.Is(err, errWorkflowNotFound) { - return nil, huma.Error404NotFound("workflow " + workflowID + " not found") + return nil, apierr.WorkflowNotFound.Msg("workflow " + workflowID + " not found") } - return nil, huma.Error500InternalServerError("workflow snapshot failed") + return nil, apierr.Internal.Msg("workflow snapshot failed") } return &IndexOutput[workflowSnapshotResponse]{ @@ -628,7 +629,7 @@ func (s *Server) humaHandleWorkflowDelete(_ context.Context, input *WorkflowDele ) { workflowID := strings.TrimSpace(input.WorkflowID) if workflowID == "" { - return nil, huma.Error400BadRequest("convoy id is required") + return nil, apierr.InvalidRequest.Msg("convoy id is required") } scopeKind := strings.TrimSpace(input.ScopeKind) @@ -755,7 +756,7 @@ func (s *Server) humaHandleWorkflowDelete(_ context.Context, input *WorkflowDele } if !found { - return nil, huma.Error404NotFound("workflow " + workflowID + " not found") + return nil, apierr.WorkflowNotFound.Msg("workflow " + workflowID + " not found") } return &struct { diff --git a/internal/api/huma_handlers_events.go b/internal/api/huma_handlers_events.go index 50583f816d..4d40b03128 100644 --- a/internal/api/huma_handlers_events.go +++ b/internal/api/huma_handlers_events.go @@ -9,6 +9,7 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/sse" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/events" ) @@ -61,7 +62,7 @@ func (s *Server) humaHandleEventList(ctx context.Context, input *EventListInput) if tp, ok := ep.(events.TailProvider); ok { evts, err := tp.ListTail(filter, limit) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } wires := toWireEvents(evts) // Total is best-effort here: when the caller narrowed with @@ -94,7 +95,7 @@ func (s *Server) humaHandleEventList(ctx context.Context, input *EventListInput) evts, err := ep.List(filter) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } wires := toWireEvents(evts) @@ -149,7 +150,7 @@ func parseEventSince(value string) (time.Duration, bool, error) { } d, err := time.ParseDuration(value) if err != nil { - return 0, false, huma.Error400BadRequest("invalid since duration: " + err.Error()) + return 0, false, apierr.InvalidRequest.Msg("invalid since duration: " + err.Error()) } return d, true, nil } @@ -160,7 +161,7 @@ func parseEventSince(value string) (time.Duration, bool, error) { func (s *Server) humaHandleEventEmit(_ context.Context, input *EventEmitInput) (*EventEmitOutput, error) { ep := s.state.EventProvider() if ep == nil { - return nil, huma.Error503ServiceUnavailable("events not enabled") + return nil, apierr.ServiceUnavailable.Msg("events not enabled") } ep.Record(events.Event{ @@ -181,14 +182,14 @@ func (s *Server) humaHandleEventRotate(ctx context.Context, input *EventRotateIn ep := s.state.EventProvider() rec, ok := ep.(*events.FileRecorder) if !ok { - return nil, huma.Error405MethodNotAllowed( + return nil, apierr.MethodNotAllowed.Msg( fmt.Sprintf("rotation is only supported for the file-backed events provider; current provider is '%s'", eventProviderName(s.state, ep)), ) } result, err := rec.ForceRotate() if err != nil { - return nil, huma.Error500InternalServerError("rotation failed: " + err.Error()) + return nil, apierr.Internal.Msg("rotation failed: " + err.Error()) } compressionStatus := "pending" @@ -255,7 +256,7 @@ func eventRotateResponseFromResult(result events.RotationResult, compressionStat // the response is committed so it can return proper HTTP errors. func (s *Server) checkEventStream(_ context.Context, _ *EventStreamInput) error { if s.state.EventProvider() == nil { - return huma.Error503ServiceUnavailable("events not enabled") + return apierr.ServiceUnavailable.Msg("events not enabled") } return nil } diff --git a/internal/api/huma_handlers_extmsg.go b/internal/api/huma_handlers_extmsg.go index 4f914ea708..bd8204c490 100644 --- a/internal/api/huma_handlers_extmsg.go +++ b/internal/api/huma_handlers_extmsg.go @@ -8,7 +8,7 @@ import ( "strings" "time" - "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/events" "github.com/gastownhall/gascity/internal/extmsg" ) @@ -20,7 +20,7 @@ import ( func (s *Server) humaExtmsgServices() (*extmsg.Services, error) { svc := s.state.ExtMsgServices() if svc == nil { - return nil, huma.Error503ServiceUnavailable("external messaging not enabled") + return nil, apierr.ServiceUnavailable.Msg("external messaging not enabled") } return svc, nil } @@ -30,7 +30,7 @@ func (s *Server) humaExtmsgServices() (*extmsg.Services, error) { func (s *Server) humaExtmsgAdapterRegistry() (*extmsg.AdapterRegistry, error) { reg := s.state.AdapterRegistry() if reg == nil { - return nil, huma.Error503ServiceUnavailable("adapter registry not available") + return nil, apierr.ServiceUnavailable.Msg("adapter registry not available") } return reg, nil } @@ -84,9 +84,9 @@ func (s *Server) humaHandleExtMsgInbound(ctx context.Context, input *ExtMsgInbou case errors.Is(handleErr, extmsg.ErrInvalidInput), errors.Is(handleErr, extmsg.ErrInvalidConversation), errors.Is(handleErr, extmsg.ErrInvariantViolation): - return nil, huma.Error400BadRequest(handleErr.Error()) + return nil, apierr.InvalidRequest.Msg(handleErr.Error()) default: - return nil, huma.Error500InternalServerError(handleErr.Error()) + return nil, apierr.Internal.Msg(handleErr.Error()) } } go s.extmsgNotifyInboundMembers(s.backgroundCtx(), *input.Body.Message) @@ -102,7 +102,7 @@ func (s *Server) humaHandleExtMsgInbound(ctx context.Context, input *ExtMsgInbou // the check stays here rather than in the schema — the schema can't // express conditional-on-sibling requiredness cleanly. if input.Body.Provider == "" || input.Body.AccountID == "" { - return nil, huma.Error400BadRequest("provider and account_id are required for raw payloads") + return nil, apierr.InvalidRequest.Msg("provider and account_id are required for raw payloads") } key := extmsg.AdapterKey{Provider: input.Body.Provider, AccountID: input.Body.AccountID} @@ -121,7 +121,7 @@ func (s *Server) humaHandleExtMsgInbound(ctx context.Context, input *ExtMsgInbou // future adapter that actually verifies raw payloads must apply the same // errors.Is split used above (4xx for the deterministic adapter/input // rejections, 5xx for transient store faults). - return nil, huma.Error422UnprocessableEntity(err.Error()) + return nil, apierr.ValidationFailed.Msg(err.Error()) } out := &ExtMsgInboundOutput{} if result != nil { @@ -159,7 +159,7 @@ func (s *Server) humaHandleExtMsgOutbound(ctx context.Context, input *ExtMsgOutb IdempotencyKey: input.Body.IdempotencyKey, }) if err != nil { - return nil, huma.Error422UnprocessableEntity(err.Error()) + return nil, apierr.ValidationFailed.Msg(err.Error()) } if result != nil && result.Receipt.Delivered { notifyConversation := input.Body.Conversation @@ -186,12 +186,12 @@ func (s *Server) humaHandleExtMsgBindingList(ctx context.Context, input *ExtMsgB } if input.SessionID == "" { - return nil, huma.Error400BadRequest("session_id query parameter is required") + return nil, apierr.InvalidRequest.Msg("session_id query parameter is required") } bindings, err := svc.Bindings.ListBySession(ctx, input.SessionID) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } if bindings == nil { bindings = []extmsg.SessionBindingRecord{} @@ -215,9 +215,9 @@ func (s *Server) humaHandleExtMsgBind(ctx context.Context, input *ExtMsgBindInpu agentName := strings.TrimSpace(input.Body.AgentName) switch { case sessionID == "" && agentName == "": - return nil, huma.Error400BadRequest("session_id or agent_name is required") + return nil, apierr.InvalidRequest.Msg("session_id or agent_name is required") case sessionID != "" && agentName != "": - return nil, huma.Error400BadRequest("session_id and agent_name are mutually exclusive") + return nil, apierr.InvalidRequest.Msg("session_id and agent_name are mutually exclusive") } if agentName != "" { // Agent bindings are resolved at delivery time, so the name must @@ -227,10 +227,10 @@ func (s *Server) humaHandleExtMsgBind(ctx context.Context, input *ExtMsgBindInpu // a later config change makes the bare name ambiguous. spec, ok, err := s.findNamedSessionSpecForTarget(s.state.CityBeadStore(), agentName) if err != nil { - return nil, huma.Error400BadRequest(fmt.Sprintf("resolving agent %q: %s", agentName, err)) + return nil, apierr.InvalidRequest.Msg(fmt.Sprintf("resolving agent %q: %s", agentName, err)) } if !ok { - return nil, huma.Error400BadRequest(fmt.Sprintf("agent %q does not resolve to a configured named session; agent bindings require a named-session-backed agent", agentName)) + return nil, apierr.InvalidRequest.Msg(fmt.Sprintf("agent %q does not resolve to a configured named session; agent bindings require a named-session-backed agent", agentName)) } agentName = spec.Identity } @@ -247,11 +247,11 @@ func (s *Server) humaHandleExtMsgBind(ctx context.Context, input *ExtMsgBindInpu if err != nil { switch { case errors.Is(err, extmsg.ErrBindingConflict): - return nil, huma.Error409Conflict(err.Error()) + return nil, apierr.ConflictWrongState.Msg(err.Error()) case errors.Is(err, extmsg.ErrInvalidInput) || errors.Is(err, extmsg.ErrInvalidConversation): - return nil, huma.Error400BadRequest(err.Error()) + return nil, apierr.InvalidRequest.Msg(err.Error()) default: - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } } @@ -289,7 +289,7 @@ func (s *Server) humaHandleExtMsgUnbind(ctx context.Context, input *ExtMsgUnbind sessionID := strings.TrimSpace(input.Body.SessionID) agentName := strings.TrimSpace(input.Body.AgentName) if input.Body.Conversation == nil && sessionID == "" && agentName == "" { - return nil, huma.Error400BadRequest("conversation, session_id, or agent_name is required") + return nil, apierr.InvalidRequest.Msg("conversation, session_id, or agent_name is required") } caller := extmsg.Caller{Kind: extmsg.CallerController, ID: "api"} @@ -300,7 +300,7 @@ func (s *Server) humaHandleExtMsgUnbind(ctx context.Context, input *ExtMsgUnbind Now: time.Now(), }) if err != nil { - return nil, huma.Error422UnprocessableEntity(err.Error()) + return nil, apierr.ValidationFailed.Msg(err.Error()) } subject := sessionID @@ -337,9 +337,9 @@ func (s *Server) humaHandleExtMsgGroupLookup(ctx context.Context, input *ExtMsgG group, err := svc.Groups.FindByConversation(ctx, caller, ref) if err != nil { if errors.Is(err, extmsg.ErrGroupNotFound) { - return nil, huma.Error404NotFound("group not found for conversation") + return nil, apierr.ExtmsgGroupNotFound.Msg("group not found for conversation") } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } out := &ExtMsgGroupOutput{} if group != nil { @@ -368,7 +368,7 @@ func (s *Server) humaHandleExtMsgGroupEnsure(ctx context.Context, input *ExtMsgG Metadata: input.Body.Metadata, }) if err != nil { - return nil, huma.Error422UnprocessableEntity(err.Error()) + return nil, apierr.ValidationFailed.Msg(err.Error()) } s.extmsgEmitEvent()(events.ExtMsgGroupCreated, group.ID, extmsg.GroupCreatedEventPayload{ @@ -399,7 +399,7 @@ func (s *Server) humaHandleExtMsgParticipantUpsert(ctx context.Context, input *E Metadata: input.Body.Metadata, }) if err != nil { - return nil, huma.Error422UnprocessableEntity(err.Error()) + return nil, apierr.ValidationFailed.Msg(err.Error()) } out := &ExtMsgParticipantOutput{} out.Body = participant @@ -419,7 +419,7 @@ func (s *Server) humaHandleExtMsgParticipantRemove(ctx context.Context, input *E Handle: input.Body.Handle, }) if err != nil { - return nil, huma.Error422UnprocessableEntity(err.Error()) + return nil, apierr.ValidationFailed.Msg(err.Error()) } out := &OKResponse{} out.Body.Status = "removed" @@ -453,7 +453,7 @@ func (s *Server) humaHandleExtMsgTranscriptList(ctx context.Context, input *ExtM Order: extmsg.TranscriptOrder(input.Order), }) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } if entries == nil { entries = []extmsg.ConversationTranscriptRecord{} @@ -479,7 +479,7 @@ func (s *Server) humaHandleExtMsgTranscriptAck(ctx context.Context, input *ExtMs Sequence: input.Body.Sequence, }) if err != nil { - return nil, huma.Error422UnprocessableEntity(err.Error()) + return nil, apierr.ValidationFailed.Msg(err.Error()) } out := &OKResponse{} out.Body.Status = "acked" diff --git a/internal/api/huma_handlers_formula_write.go b/internal/api/huma_handlers_formula_write.go index 08ba602c71..783a08fe6f 100644 --- a/internal/api/huma_handlers_formula_write.go +++ b/internal/api/huma_handlers_formula_write.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/configedit" "github.com/gastownhall/gascity/internal/formula" @@ -58,7 +59,7 @@ func (s *Server) humaHandleFormulaSource(_ context.Context, input *FormulaSource return nil, mutationError(err) } if !found { - return nil, huma.Error404NotFound("no editable city-local formula " + input.Name) + return nil, apierr.FormulaNotFound.Msg("no editable city-local formula " + input.Name) } out := &FormulaSourceOutput{} out.Body.Name = input.Name @@ -101,7 +102,7 @@ func (s *Server) humaHandleFormulaUpsert(_ context.Context, input *FormulaUpsert return nil, errMutationsNotSupported } if errs := validateFormulaSource(s.state.Config(), input.Name, input.RawBody); len(errs) > 0 { - return nil, huma.Error400BadRequest("formula validation failed: " + strings.Join(errs, "; ")) + return nil, apierr.InvalidRequest.Msg("formula validation failed: " + strings.Join(errs, "; ")) } if err := fm.UpsertFormula(input.Name, input.RawBody); err != nil { return nil, mutationError(err) diff --git a/internal/api/huma_handlers_formulas.go b/internal/api/huma_handlers_formulas.go index 479ac23112..1187cb0cf3 100644 --- a/internal/api/huma_handlers_formulas.go +++ b/internal/api/huma_handlers_formulas.go @@ -8,7 +8,7 @@ import ( "strings" "time" - "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/beads" ) @@ -28,23 +28,23 @@ type FormulaListOutput struct { func (s *Server) humaHandleFormulaList(_ context.Context, input *FormulaListInput) (*FormulaListOutput, error) { scopeKind, scopeRef, scopeErr := parseWorkflowRequestScope(input.ScopeKind, input.ScopeRef) if scopeErr != "" { - return nil, huma.Error400BadRequest(scopeErr) + return nil, apierr.InvalidRequest.Msg(scopeErr) } paths, status, msg := s.formulaSearchPaths(scopeKind, scopeRef) if status != 200 { if status == 404 { - return nil, huma.Error404NotFound(msg) + return nil, apierr.ScopeNotFound.Msg(msg) } if status == 503 { - return nil, huma.Error503ServiceUnavailable(msg) + return nil, apierr.ServiceUnavailable.Msg(msg) } - return nil, huma.Error400BadRequest(msg) + return nil, apierr.InvalidRequest.Msg(msg) } items, err := buildFormulaCatalog(paths) if err != nil { - return nil, huma.Error500InternalServerError("formula catalog failed") + return nil, apierr.Internal.Msg("formula catalog failed") } out := &FormulaListOutput{} @@ -64,16 +64,16 @@ func (s *Server) humaHandleFormulaRuns(_ context.Context, input *FormulaRunsInpu scopeKind, scopeRef, scopeErr := parseWorkflowRequestScope(input.ScopeKind, input.ScopeRef) if scopeErr != "" { - return nil, huma.Error400BadRequest(scopeErr) + return nil, apierr.InvalidRequest.Msg(scopeErr) } if _, status, msg := s.formulaSearchPaths(scopeKind, scopeRef); status != 200 { if status == 404 { - return nil, huma.Error404NotFound(msg) + return nil, apierr.ScopeNotFound.Msg(msg) } if status == 503 { - return nil, huma.Error503ServiceUnavailable(msg) + return nil, apierr.ServiceUnavailable.Msg(msg) } - return nil, huma.Error400BadRequest(msg) + return nil, apierr.InvalidRequest.Msg(msg) } limit := defaultFormulaRunsLimit @@ -83,7 +83,7 @@ func (s *Server) humaHandleFormulaRuns(_ context.Context, input *FormulaRunsInpu resp, err := buildFormulaRuns(s.state, name, scopeKind, scopeRef, limit) if err != nil { - return nil, huma.Error500InternalServerError("formula runs failed") + return nil, apierr.Internal.Msg("formula runs failed") } return &struct { @@ -130,27 +130,27 @@ func (s *Server) formulaDetail(ctx context.Context, rawName, rawScopeKind, rawSc ) { name := strings.TrimSpace(rawName) if name == "" { - return nil, huma.Error400BadRequest("formula name is required") + return nil, apierr.InvalidRequest.Msg("formula name is required") } scopeKind, scopeRef, scopeErr := parseWorkflowRequestScope(rawScopeKind, rawScopeRef) if scopeErr != "" { - return nil, huma.Error400BadRequest(scopeErr) + return nil, apierr.InvalidRequest.Msg(scopeErr) } target := strings.TrimSpace(rawTarget) if target == "" { - return nil, huma.Error400BadRequest("target is required") + return nil, apierr.InvalidRequest.Msg("target is required") } paths, status, msg := s.formulaSearchPaths(scopeKind, scopeRef) if status != 200 { if status == 404 { - return nil, huma.Error404NotFound(msg) + return nil, apierr.ScopeNotFound.Msg(msg) } if status == 503 { - return nil, huma.Error503ServiceUnavailable(msg) + return nil, apierr.ServiceUnavailable.Msg(msg) } - return nil, huma.Error400BadRequest(msg) + return nil, apierr.InvalidRequest.Msg(msg) } // Workflow roots persist the routed agent identity as gc.routed_to @@ -171,7 +171,7 @@ func (s *Server) formulaDetail(ctx context.Context, rawName, rawScopeKind, rawSc detail, err := buildFormulaDetail(ctx, store, name, paths, target, targetIsRoutingIdentity, vars, validateRuntimeVars) if err != nil { if errors.Is(err, errFormulaNotWorkflow) || errors.Is(err, errFormulaNotFound) { - return nil, huma.Error404NotFound(err.Error()) + return nil, apierr.FormulaNotFound.Msg(err.Error()) } errMsg := err.Error() // A not-found target already failed the configured-agent identity @@ -180,7 +180,7 @@ func (s *Server) formulaDetail(ctx context.Context, rawName, rawScopeKind, rawSc if !targetIsRoutingIdentity && errors.Is(err, beads.ErrNotFound) { errMsg += "; target matches neither a bead/convoy nor a configured agent identity" } - return nil, huma.Error400BadRequest(errMsg) + return nil, apierr.InvalidRequest.Msg(errMsg) } return &struct { @@ -202,16 +202,16 @@ func (s *Server) humaHandleFormulaFeed(_ context.Context, input *FormulaFeedInpu ) { scopeKind, scopeRef, scopeErr := parseWorkflowRequestScope(input.ScopeKind, input.ScopeRef) if scopeErr != "" { - return nil, huma.Error400BadRequest(scopeErr) + return nil, apierr.InvalidRequest.Msg(scopeErr) } if _, status, msg := s.formulaSearchPaths(scopeKind, scopeRef); status != http.StatusOK { if status == http.StatusNotFound { - return nil, huma.Error404NotFound(msg) + return nil, apierr.ScopeNotFound.Msg(msg) } if status == http.StatusServiceUnavailable { - return nil, huma.Error503ServiceUnavailable(msg) + return nil, apierr.ServiceUnavailable.Msg(msg) } - return nil, huma.Error400BadRequest(msg) + return nil, apierr.InvalidRequest.Msg(msg) } limit := normalizeFeedLimit(input.Limit) @@ -233,7 +233,7 @@ func (s *Server) humaHandleFormulaFeed(_ context.Context, input *FormulaFeedInpu projections, err := buildWorkflowRunProjectionsRootOnly(s.state, scopeKind, scopeRef) if err != nil { - return nil, huma.Error500InternalServerError("formula feed failed") + return nil, apierr.Internal.Msg("formula feed failed") } items := make([]monitorFeedItemResponse, 0, len(projections.Items)) diff --git a/internal/api/huma_handlers_mail.go b/internal/api/huma_handlers_mail.go index ce2509154d..fc213cd9f9 100644 --- a/internal/api/huma_handlers_mail.go +++ b/internal/api/huma_handlers_mail.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/events" "github.com/gastownhall/gascity/internal/mail" "github.com/gastownhall/gascity/internal/telemetry" @@ -159,9 +159,9 @@ func orderedMailProviderReadResults[T any](names []string, results map[string]ma func mailReadAPIError(err error) error { var timeoutErr *mailReadTimeoutError if errors.As(err, &timeoutErr) { - return huma.Error503ServiceUnavailable(timeoutErr.Error()) + return apierr.ServiceUnavailable.Msg(timeoutErr.Error()) } - return huma.Error500InternalServerError(err.Error()) + return apierr.Internal.Msg(err.Error()) } func allMailProvidersFailedError(partialErrs []string, storeSlow bool) error { @@ -169,7 +169,7 @@ func allMailProvidersFailedError(partialErrs []string, storeSlow bool) error { if storeSlow { detail = "store_slow: " + detail } - return huma.Error503ServiceUnavailable(detail) + return apierr.ServiceUnavailable.Msg(detail) } // humaHandleMailList is the Huma-typed handler for GET /v0/mail. @@ -374,7 +374,7 @@ func (s *Server) humaHandleMailList(ctx context.Context, input *MailListInput) ( }, nil default: - return nil, huma.Error400BadRequest("unsupported status filter: " + status + "; supported: unread, all") + return nil, apierr.InvalidRequest.Msg("unsupported status filter: " + status + "; supported: unread, all") } } @@ -402,12 +402,12 @@ func (s *Server) humaHandleMailGet(ctx context.Context, input *MailGetInput) (*I }) if err != nil { if errors.Is(err, mail.ErrNotFound) { - return nil, huma.Error404NotFound(err.Error()) + return nil, apierr.MailNotFound.Msg(err.Error()) } return nil, mailReadAPIError(err) } if !result.Found { - return nil, huma.Error404NotFound("message " + id + " not found") + return nil, apierr.MailNotFound.Msg("message " + id + " not found") } result.Message.Rig = result.Rig return &IndexOutput[mail.Message]{ @@ -424,14 +424,14 @@ func (s *Server) humaHandleMailSend(ctx context.Context, input *MailSendInput) ( resolved, resolveErr := s.resolveMailSendRecipientWithContext(ctx, input.Body.To) if resolveErr != nil { if errors.Is(resolveErr, errMailNoBeadStore) { - return nil, huma.Error400BadRequest(resolveErr.Error()) + return nil, apierr.InvalidRequest.Msg(resolveErr.Error()) } - return nil, huma.Error400BadRequest(resolveErr.Error()) + return nil, apierr.InvalidRequest.Msg(resolveErr.Error()) } mp := s.findMailProvider(input.Body.Rig) if mp == nil { - return nil, huma.Error400BadRequest("no mail provider available") + return nil, apierr.InvalidRequest.Msg("no mail provider available") } // Idempotency check — scope by method+path to prevent cross-endpoint collisions. @@ -443,10 +443,10 @@ func (s *Server) humaHandleMailSend(ctx context.Context, input *MailSendInput) ( existing, found := s.idem.reserve(idemKey, bodyHash) if found { if existing.bodyHash != bodyHash { - return nil, huma.Error422UnprocessableEntity("idempotency_mismatch: Idempotency-Key reused with different request body") + return nil, apierr.IdempotencyMismatch.Msg("idempotency_mismatch: Idempotency-Key reused with different request body") } if existing.pending { - return nil, huma.Error409Conflict("in_flight: request with this Idempotency-Key is already in progress") + return nil, apierr.IdempotencyInFlight.Msg("in_flight: request with this Idempotency-Key is already in progress") } // Replay cached typed response (Fix 3l). if msg, ok := replayAs[mail.Message](existing); ok { @@ -462,7 +462,7 @@ func (s *Server) humaHandleMailSend(ctx context.Context, input *MailSendInput) ( telemetry.RecordMailOp(ctx, "send", err) if err != nil { s.idem.unreserve(idemKey) - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } msg.Rig = input.Body.Rig s.idem.storeResponse(idemKey, bodyHash, msg) @@ -545,7 +545,7 @@ func (s *Server) humaHandleMailThread(ctx context.Context, input *MailThreadInpu if rig != "" { mp := s.state.MailProvider(rig) if mp == nil { - return nil, huma.Error404NotFound("rig " + rig + " not found") + return nil, apierr.RigNotFound.Msg("rig " + rig + " not found") } msgs, err := withMailReadDeadline(ctx, func() ([]mail.Message, error) { return mp.Thread(threadID) @@ -599,18 +599,18 @@ func (s *Server) humaHandleMailRead(ctx context.Context, input *MailReadInput) ( rig := input.Rig mp, resolvedRig, err := s.findMailProviderForMessage(id, rig) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } if mp == nil { - return nil, huma.Error404NotFound("message " + id + " not found") + return nil, apierr.MailNotFound.Msg("message " + id + " not found") } if err := mp.MarkRead(id); err != nil { telemetry.RecordMailOp(ctx, "mark_read", err) - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } telemetry.RecordMailOp(ctx, "mark_read", nil) if err := waitForMailReadState(ctx, mp, id, true); err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } s.recordMailEvent(events.MailMarkedRead, "api", id, resolvedRig, nil) resp := &OKResponse{} @@ -624,18 +624,18 @@ func (s *Server) humaHandleMailMarkUnread(ctx context.Context, input *MailMarkUn rig := input.Rig mp, resolvedRig, err := s.findMailProviderForMessage(id, rig) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } if mp == nil { - return nil, huma.Error404NotFound("message " + id + " not found") + return nil, apierr.MailNotFound.Msg("message " + id + " not found") } if err := mp.MarkUnread(id); err != nil { telemetry.RecordMailOp(ctx, "mark_unread", err) - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } telemetry.RecordMailOp(ctx, "mark_unread", nil) if err := waitForMailReadState(ctx, mp, id, false); err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } s.recordMailEvent(events.MailMarkedUnread, "api", id, resolvedRig, nil) resp := &OKResponse{} @@ -673,7 +673,7 @@ func (s *Server) humaHandleMailArchive(ctx context.Context, input *MailArchiveIn rig := input.Rig mp, resolvedRig, err := s.findMailProviderForMessage(id, rig) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } if mp == nil { // Idempotent: archive removes the bead, so a repeat call finds no @@ -689,7 +689,7 @@ func (s *Server) humaHandleMailArchive(ctx context.Context, input *MailArchiveIn return resp, nil } telemetry.RecordMailOp(ctx, "archive", err) - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } telemetry.RecordMailOp(ctx, "archive", nil) s.recordMailEvent(events.MailArchived, "api", id, resolvedRig, nil) @@ -705,16 +705,16 @@ func (s *Server) humaHandleMailReply(ctx context.Context, input *MailReplyInput) mp, resolvedRig, mpErr := s.findMailProviderForMessage(id, rig) if mpErr != nil { - return nil, huma.Error500InternalServerError(mpErr.Error()) + return nil, apierr.Internal.Msg(mpErr.Error()) } if mp == nil { - return nil, huma.Error404NotFound("message " + id + " not found") + return nil, apierr.MailNotFound.Msg("message " + id + " not found") } msg, err := mp.Reply(id, input.Body.From, input.Body.Subject, input.Body.Body) telemetry.RecordMailOp(ctx, "reply", err) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } msg.Rig = resolvedRig s.recordMailEvent(events.MailReplied, msg.From, msg.ID, resolvedRig, &msg) @@ -731,7 +731,7 @@ func (s *Server) humaHandleMailDelete(ctx context.Context, input *MailDeleteInpu rig := input.Rig mp, resolvedRig, err := s.findMailProviderForMessage(id, rig) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } if mp == nil { // Idempotent: delete removes the bead, so a repeat call finds no @@ -747,7 +747,7 @@ func (s *Server) humaHandleMailDelete(ctx context.Context, input *MailDeleteInpu return resp, nil } telemetry.RecordMailOp(ctx, "delete", err) - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } telemetry.RecordMailOp(ctx, "delete", nil) s.recordMailEvent(events.MailDeleted, "api", id, resolvedRig, nil) diff --git a/internal/api/huma_handlers_orders.go b/internal/api/huma_handlers_orders.go index 7380e1c60d..c1c57cb1e6 100644 --- a/internal/api/huma_handlers_orders.go +++ b/internal/api/huma_handlers_orders.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/beadmeta" "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/convergence" @@ -47,9 +47,9 @@ func (s *Server) humaHandleOrderGet(_ context.Context, input *OrderGetInput) (*s a, err := resolveOrder(s.state.OrdersAll(), input.Name) if err != nil { if errors.Is(err, errOrderAmbiguous) { - return nil, huma.Error409Conflict(err.Error()) + return nil, apierr.AmbiguousReference.Msg(err.Error()) } - return nil, huma.Error404NotFound(err.Error()) + return nil, apierr.OrderNotFound.Msg(err.Error()) } return &struct { Body orderResponse @@ -185,7 +185,7 @@ func (s *Server) humaHandleOrderHistory(_ context.Context, input *OrderHistoryIn } scopedName := input.ScopedName if scopedName == "" { - return nil, huma.Error400BadRequest("scoped_name is required") + return nil, apierr.InvalidRequest.Msg("scoped_name is required") } limit := 20 @@ -197,7 +197,7 @@ func (s *Server) humaHandleOrderHistory(_ context.Context, input *OrderHistoryIn if input.Before != "" { t, err := time.Parse(time.RFC3339, input.Before) if err != nil { - return nil, huma.Error400BadRequest("invalid before timestamp: must be RFC3339, got " + strconv.Quote(input.Before)) + return nil, apierr.InvalidRequest.Msg("invalid before timestamp: must be RFC3339, got " + strconv.Quote(input.Before)) } beforeTime = t } @@ -222,14 +222,14 @@ func (s *Server) humaHandleOrderHistory(_ context.Context, input *OrderHistoryIn storeInfos, err := orderStoreInfosForState(s.state, orderDef) if err != nil { if errors.Is(err, errNoOrderStores) { - return nil, huma.Error503ServiceUnavailable(err.Error()) + return nil, apierr.ServiceUnavailable.Msg(err.Error()) } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } results, err := orderHistoryBeadsAcrossStoreInfos(storeInfos, scopedName, limit, beforeTime) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } entries := make([]orderHistoryEntry, 0, len(results)) @@ -305,19 +305,19 @@ func (s *Server) humaHandleOrderHistoryDetail(_ context.Context, input *OrderHis if input.StoreRef != "" { info, ok := workflowStoreByRef(s.state, input.StoreRef) if !ok { - return nil, huma.Error404NotFound("store not found") + return nil, apierr.ScopeNotFound.Msg("store_ref does not resolve to a known scope") } storeInfos = []workflowStoreInfo{info} } result, err := orderHistoryBeadAcrossStoreInfos(storeInfos, input.BeadID) if err != nil { if errors.Is(err, beads.ErrNotFound) { - return nil, huma.Error404NotFound("bead not found") + return nil, apierr.BeadNotFound.Msg("bead not found") } if errors.Is(err, errNoOrderStores) { - return nil, huma.Error503ServiceUnavailable(err.Error()) + return nil, apierr.ServiceUnavailable.Msg(err.Error()) } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } b := result.bead @@ -558,7 +558,7 @@ func (s *Server) humaHandleOrdersFeed(_ context.Context, input *OrdersFeedInput) ) { scopeKind, scopeRef, scopeErr := parseWorkflowRequestScope(input.ScopeKind, input.ScopeRef) if scopeErr != "" { - return nil, huma.Error400BadRequest(scopeErr) + return nil, apierr.InvalidRequest.Msg(scopeErr) } limit := normalizeFeedLimit(input.Limit) @@ -573,11 +573,11 @@ func (s *Server) humaHandleOrdersFeed(_ context.Context, input *OrdersFeedInput) workflowRuns, err := buildWorkflowRunProjections(s.state, scopeKind, scopeRef, "") if err != nil { - return nil, huma.Error500InternalServerError("workflow feed failed") + return nil, apierr.Internal.Msg("workflow feed failed") } orderRuns, err := buildOrderRunFeedItems(s.state, scopeKind, scopeRef) if err != nil { - return nil, huma.Error500InternalServerError("order feed failed") + return nil, apierr.Internal.Msg("order feed failed") } items := make([]monitorFeedItemResponse, 0, len(workflowRuns.Items)+len(orderRuns.Items)) @@ -647,9 +647,9 @@ func (s *Server) setOrderEnabledHuma(name string, enabled bool) (*OKResponse, er a, err := resolveOrder(s.state.OrdersAll(), name) if err != nil { if errors.Is(err, errOrderAmbiguous) { - return nil, huma.Error409Conflict(err.Error()) + return nil, apierr.AmbiguousReference.Msg(err.Error()) } - return nil, huma.Error404NotFound(err.Error()) + return nil, apierr.OrderNotFound.Msg(err.Error()) } if enabled { diff --git a/internal/api/huma_handlers_packs.go b/internal/api/huma_handlers_packs.go index 8a1f924baf..b36305cc37 100644 --- a/internal/api/huma_handlers_packs.go +++ b/internal/api/huma_handlers_packs.go @@ -6,6 +6,7 @@ import ( "sort" "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/fsys" "github.com/gastownhall/gascity/internal/importsvc" ) @@ -172,21 +173,21 @@ func packImportHTTPError(err error) error { // ErrNameDerive and ErrReservedPrefix are client input-validation failures // (no derivable name, or a reserved "default-rig:" name), so they are 400s // like ErrInvalidSource, not 500s. - return huma.Error400BadRequest(err.Error()) + return apierr.InvalidRequest.Msg(err.Error()) case errors.Is(err, importsvc.ErrImportExists): - return huma.Error409Conflict(err.Error()) + return apierr.ConflictWrongState.Msg(err.Error()) case errors.Is(err, importsvc.ErrNotFound): - return huma.Error404NotFound(err.Error()) + return apierr.PackNotFound.Msg(err.Error()) case errors.Is(err, importsvc.ErrVersionResolveFailed): // Resolving the operator-named source via `git ls-remote` is a genuinely // upstream dependency, so a failure here is a bad gateway. - return huma.Error502BadGateway(err.Error()) + return apierr.BadGateway.Msg(err.Error()) case errors.Is(err, importsvc.ErrInstallFailed): // ErrInstallFailed wraps LOCAL failures too (the import-graph read, // manifest save, lockfile write), not just an upstream clone, so it maps // to a server error — matching importsvc's documented HTTP 500. - return huma.Error500InternalServerError("pack install failed", err) + return apierr.Internal.With("pack install failed", &huma.ErrorDetail{Message: err.Error()}) default: - return huma.Error500InternalServerError("pack import failed", err) + return apierr.Internal.With("pack import failed", &huma.ErrorDetail{Message: err.Error()}) } } diff --git a/internal/api/huma_handlers_patches.go b/internal/api/huma_handlers_patches.go index 9965418ea1..74229e3b86 100644 --- a/internal/api/huma_handlers_patches.go +++ b/internal/api/huma_handlers_patches.go @@ -3,7 +3,7 @@ package api import ( "context" - "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/config" ) @@ -45,7 +45,7 @@ func (s *Server) agentPatchByName(name string) (*IndexOutput[config.AgentPatch], }, nil } } - return nil, huma.Error404NotFound("agent patch " + name + " not found") + return nil, apierr.PatchNotFound.Msg("agent patch " + name + " not found") } // humaHandleAgentPatchSet is the Huma-typed handler for PUT /v0/patches/agents. @@ -67,7 +67,7 @@ func (s *Server) humaHandleAgentPatchSet(_ context.Context, input *AgentPatchSet } if patch.Name == "" { - return nil, huma.Error400BadRequest("name is required") + return nil, apierr.InvalidRequest.Msg("name is required") } if err := sm.SetAgentPatch(patch); err != nil { @@ -137,7 +137,7 @@ func (s *Server) humaHandleRigPatchGet(_ context.Context, input *RigPatchGetInpu }, nil } } - return nil, huma.Error404NotFound("rig patch " + name + " not found") + return nil, apierr.PatchNotFound.Msg("rig patch " + name + " not found") } // humaHandleRigPatchSet is the Huma-typed handler for PUT /v0/patches/rigs. @@ -156,7 +156,7 @@ func (s *Server) humaHandleRigPatchSet(_ context.Context, input *RigPatchSetInpu } if patch.Name == "" { - return nil, huma.Error400BadRequest("name is required") + return nil, apierr.InvalidRequest.Msg("name is required") } if err := sm.SetRigPatch(patch); err != nil { @@ -212,7 +212,7 @@ func (s *Server) humaHandleProviderPatchGet(_ context.Context, input *ProviderPa }, nil } } - return nil, huma.Error404NotFound("provider patch " + name + " not found") + return nil, apierr.PatchNotFound.Msg("provider patch " + name + " not found") } // humaHandleProviderPatchSet is the Huma-typed handler for PUT /v0/patches/providers. @@ -236,7 +236,7 @@ func (s *Server) humaHandleProviderPatchSet(_ context.Context, input *ProviderPa } if patch.Name == "" { - return nil, huma.Error400BadRequest("name is required") + return nil, apierr.InvalidRequest.Msg("name is required") } if err := sm.SetProviderPatch(patch); err != nil { diff --git a/internal/api/huma_handlers_providers.go b/internal/api/huma_handlers_providers.go index ecfc82532e..5e956f47f1 100644 --- a/internal/api/huma_handlers_providers.go +++ b/internal/api/huma_handlers_providers.go @@ -5,7 +5,7 @@ import ( "sort" "strings" - "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/config" ) @@ -125,7 +125,7 @@ func (s *Server) humaHandleProviderGet(_ context.Context, input *ProviderGetInpu }, nil } - return nil, huma.Error404NotFound("provider " + name + " not found") + return nil, apierr.ProviderNotFound.Msg("provider " + name + " not found") } // humaHandleProviderCreate is the Huma-typed handler for POST /v0/providers. @@ -196,7 +196,7 @@ func (s *Server) humaHandleProviderUpdate(_ context.Context, input *ProviderUpda msg := err.Error() // Preserve the special builtin-override hint. if strings.Contains(msg, "not found") && isBuiltinProvider(input.Name) { - return nil, huma.Error409Conflict( + return nil, apierr.ConflictWrongState.Msg( "provider " + input.Name + " is a builtin; use PUT /v0/patches/providers to override") } return nil, mutationError(err) @@ -217,7 +217,7 @@ func (s *Server) humaHandleProviderDelete(_ context.Context, input *ProviderDele msg := err.Error() // Preserve the special builtin-override hint. if strings.Contains(msg, "not found") && isBuiltinProvider(input.Name) { - return nil, huma.Error409Conflict( + return nil, apierr.ConflictWrongState.Msg( "provider " + input.Name + " is a builtin; use DELETE /v0/patches/provider/" + input.Name + " to remove overrides") } return nil, mutationError(err) diff --git a/internal/api/huma_handlers_rigs.go b/internal/api/huma_handlers_rigs.go index 1d8c5d1907..a9157ee416 100644 --- a/internal/api/huma_handlers_rigs.go +++ b/internal/api/huma_handlers_rigs.go @@ -2,8 +2,9 @@ package api import ( "context" + "net/http" - "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/runtime" workdirutil "github.com/gastownhall/gascity/internal/workdir" @@ -59,7 +60,7 @@ func (s *Server) humaHandleRigGet(_ context.Context, input *RigGetInput) (*Index }, nil } } - return nil, huma.Error404NotFound("rig " + name + " not found") + return nil, apierr.RigNotFound.Msg("rig " + name + " not found") } // humaHandleRigCreate is the Huma-typed handler for POST /v0/rigs. @@ -153,7 +154,7 @@ func (s *Server) humaHandleRigAction(_ context.Context, input *RigActionInput) ( return s.humaHandleRigRestart(name) default: - return nil, huma.Error404NotFound("unknown rig action: " + action) + return nil, apierr.InvalidRequest.WithStatus(http.StatusNotFound, "unknown rig action: "+action) } } @@ -173,7 +174,7 @@ func (s *Server) humaHandleRigRestart(name string) (*RigActionResponse, error) { } } if !rigFound { - return nil, huma.Error404NotFound("rig " + name + " not found") + return nil, apierr.RigNotFound.Msg("rig " + name + " not found") } // Best-effort kill: the agent set may change between config read and each diff --git a/internal/api/huma_handlers_services.go b/internal/api/huma_handlers_services.go index 4ada52449c..4c8c544352 100644 --- a/internal/api/huma_handlers_services.go +++ b/internal/api/huma_handlers_services.go @@ -4,7 +4,7 @@ import ( "context" "errors" - "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/workspacesvc" ) @@ -29,11 +29,11 @@ func (s *Server) humaHandleServiceList(_ context.Context, _ *ServiceListInput) ( func (s *Server) humaHandleServiceGet(_ context.Context, input *ServiceGetInput) (*IndexOutput[workspacesvc.Status], error) { reg := s.state.ServiceRegistry() if reg == nil { - return nil, huma.Error404NotFound("service " + input.Name + " not found") + return nil, apierr.ServiceNotFound.Msg("service " + input.Name + " not found") } item, ok := reg.Get(input.Name) if !ok { - return nil, huma.Error404NotFound("service " + input.Name + " not found") + return nil, apierr.ServiceNotFound.Msg("service " + input.Name + " not found") } return &IndexOutput[workspacesvc.Status]{ Index: s.latestIndex(), @@ -46,13 +46,13 @@ func (s *Server) humaHandleServiceRestart(_ context.Context, input *ServiceResta name := input.Name reg := s.state.ServiceRegistry() if reg == nil { - return nil, huma.Error404NotFound("service " + name + " not found") + return nil, apierr.ServiceNotFound.Msg("service " + name + " not found") } if err := reg.Restart(name); err != nil { if errors.Is(err, workspacesvc.ErrServiceNotFound) { - return nil, huma.Error404NotFound(err.Error()) + return nil, apierr.ServiceNotFound.Msg(err.Error()) } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } out := &ServiceRestartOutput{} out.Body.Status = "ok" diff --git a/internal/api/huma_handlers_sessions.go b/internal/api/huma_handlers_sessions.go index fa67cc4650..db5a4d90c3 100644 --- a/internal/api/huma_handlers_sessions.go +++ b/internal/api/huma_handlers_sessions.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/session" ) @@ -24,11 +25,11 @@ import ( func humaResolveError(err error) error { switch { case errors.Is(err, session.ErrAmbiguous), errors.Is(err, errConfiguredNamedSessionConflict): - return huma.Error409Conflict("ambiguous: " + err.Error()) + return apierr.SessionConflict.Msg("ambiguous: " + err.Error()) case errors.Is(err, session.ErrSessionNotFound): - return huma.Error404NotFound("not_found: " + err.Error()) + return apierr.SessionNotFound.Msg("not_found: " + err.Error()) default: - return huma.Error500InternalServerError("internal: " + err.Error()) + return apierr.Internal.Msg("internal: " + err.Error()) } } @@ -37,29 +38,29 @@ func humaResolveError(err error) error { func humaSessionManagerError(err error) error { switch { case errors.Is(err, session.ErrInvalidSessionName): - return huma.Error400BadRequest("invalid: " + err.Error()) + return apierr.InvalidRequest.Msg("invalid: " + err.Error()) case errors.Is(err, session.ErrSessionNameExists): - return huma.Error409Conflict("conflict: " + err.Error()) + return apierr.SessionConflict.Msg("conflict: " + err.Error()) case errors.Is(err, session.ErrInvalidSessionAlias): - return huma.Error400BadRequest("invalid: " + err.Error()) + return apierr.InvalidRequest.Msg("invalid: " + err.Error()) case errors.Is(err, session.ErrSessionAliasExists): - return huma.Error409Conflict("conflict: " + err.Error()) + return apierr.SessionConflict.Msg("conflict: " + err.Error()) case errors.Is(err, session.ErrInteractionUnsupported): - return huma.Error501NotImplemented("unsupported: " + err.Error()) + return apierr.NotImplemented.Msg("unsupported: " + err.Error()) case errors.Is(err, session.ErrPendingInteraction): - return huma.Error409Conflict("pending_interaction: " + err.Error()) + return apierr.SessionConflict.Msg("pending_interaction: " + err.Error()) case errors.Is(err, session.ErrNoPendingInteraction): - return huma.Error409Conflict("no_pending: " + err.Error()) + return apierr.SessionConflict.Msg("no_pending: " + err.Error()) case errors.Is(err, session.ErrInteractionMismatch): - return huma.Error409Conflict("invalid_interaction: " + err.Error()) + return apierr.SessionConflict.Msg("invalid_interaction: " + err.Error()) case errors.Is(err, session.ErrSessionClosed), errors.Is(err, session.ErrResumeRequired): - return huma.Error409Conflict("conflict: " + err.Error()) + return apierr.SessionConflict.Msg("conflict: " + err.Error()) case errors.Is(err, session.ErrSessionActive): - return huma.Error409Conflict("conflict: " + err.Error()) + return apierr.SessionConflict.Msg("conflict: " + err.Error()) case errors.Is(err, session.ErrNotSession): - return huma.Error400BadRequest("invalid: " + err.Error()) + return apierr.InvalidRequest.Msg("invalid: " + err.Error()) case errors.Is(err, session.ErrIllegalTransition): - return huma.Error409Conflict("illegal_transition: " + err.Error()) + return apierr.SessionConflict.Msg("illegal_transition: " + err.Error()) default: return humaStoreError(err) } @@ -69,9 +70,9 @@ func humaSessionManagerError(err error) error { func humaStoreError(err error) error { if errors.Is(err, beads.ErrNotFound) { - return huma.Error404NotFound("not_found: " + err.Error()) + return apierr.SessionNotFound.Msg("not_found: " + err.Error()) } - return huma.Error500InternalServerError("internal: " + err.Error()) + return apierr.Internal.Msg("internal: " + err.Error()) } func writeHumaStatusError(w http.ResponseWriter, err error) { diff --git a/internal/api/huma_handlers_sessions_command.go b/internal/api/huma_handlers_sessions_command.go index 3db8b708fe..2ac8a16e9d 100644 --- a/internal/api/huma_handlers_sessions_command.go +++ b/internal/api/huma_handlers_sessions_command.go @@ -12,7 +12,7 @@ import ( "sync/atomic" "time" - "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/runtime" @@ -37,21 +37,21 @@ type sessionCommandableWaiter interface { func (s *Server) humaHandleSessionCreate(ctx context.Context, input *SessionCreateInput) (*SessionCreateOutput, error) { store := s.state.SessionsBeadStore() if store.Store == nil { - return nil, huma.Error503ServiceUnavailable("no bead store configured") + return nil, apierr.ServiceUnavailable.Msg("no bead store configured") } body := input.Body if body.LegacySessionName != nil { - return nil, huma.Error400BadRequest("session_name is no longer accepted; use alias") + return nil, apierr.InvalidRequest.Msg("session_name is no longer accepted; use alias") } kind := body.Kind name := body.Name if name == "" { - return nil, huma.Error400BadRequest("name is required") + return nil, apierr.InvalidRequest.Msg("name is required") } if kind != "agent" && kind != "provider" { - return nil, huma.Error400BadRequest("kind must be 'agent' or 'provider'") + return nil, apierr.InvalidRequest.Msg("kind must be 'agent' or 'provider'") } if kind == "provider" { @@ -62,24 +62,24 @@ func (s *Server) humaHandleSessionCreate(ctx context.Context, input *SessionCrea resolved, workDir, transport, template, err := s.resolveSessionTemplateWithBareNameFallback(name) if err != nil { if errors.Is(err, errSessionTemplateNotFound) { - return nil, huma.Error404NotFound("agent '" + name + "' not found") + return nil, apierr.AgentNotFound.Msg("agent '" + name + "' not found") } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } transport, err = validateSessionTransport(resolved, transport, s.state.SessionProvider()) if err != nil { - return nil, huma.Error503ServiceUnavailable(err.Error()) + return nil, apierr.ServiceUnavailable.Msg(err.Error()) } if len(body.Options) > 0 { if len(resolved.OptionsSchema) == 0 { - return nil, huma.Error400BadRequest("agent '" + name + "' does not accept options") + return nil, apierr.InvalidRequest.Msg("agent '" + name + "' does not accept options") } if _, optErr := config.ResolveExplicitOptions(resolved.OptionsSchema, body.Options); optErr != nil { if errors.Is(optErr, config.ErrUnknownOption) { - return nil, huma.Error400BadRequest(optErr.Error()) + return nil, apierr.InvalidRequest.Msg(optErr.Error()) } - return nil, huma.Error400BadRequest(optErr.Error()) + return nil, apierr.InvalidRequest.Msg(optErr.Error()) } } @@ -94,11 +94,11 @@ func (s *Server) humaHandleSessionCreate(ctx context.Context, input *SessionCrea } cfg := s.state.Config() if cfg == nil { - return nil, huma.Error500InternalServerError("no city config loaded") + return nil, apierr.Internal.Msg("no city config loaded") } createCtx, err := s.resolveAgentCreateContext(template, alias) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } agentCfg := createCtx.Agent alias = createCtx.Alias @@ -108,7 +108,7 @@ func (s *Server) humaHandleSessionCreate(ctx context.Context, input *SessionCrea launchCommand, err := config.BuildProviderLaunchCommandWithoutOptions(s.state.CityPath(), resolved, transport) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } command := launchCommand.Command extraMeta := sessionTemplateOverridesMetadata(body.Options, body.Message) @@ -119,16 +119,16 @@ func (s *Server) humaHandleSessionCreate(ctx context.Context, input *SessionCrea extraMeta["session_origin"] = "manual" mcpServers, err := s.sessionMCPServers(template, resolved.Name, workDirQualifiedName, workDir, transport, kind, nil) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } reqID, reqIDErr := newRequestID() if reqIDErr != nil { - return nil, huma.Error500InternalServerError(reqIDErr.Error()) + return nil, apierr.Internal.Msg(reqIDErr.Error()) } eventCursor, cursorErr := s.currentCityEventCursor() if cursorErr != nil { - return nil, huma.Error500InternalServerError(cursorErr.Error()) + return nil, apierr.Internal.Msg(cursorErr.Error()) } go func() { @@ -231,7 +231,7 @@ func (s *Server) humaHandleSessionCreate(ctx context.Context, input *SessionCrea func (s *Server) humaCreateProviderSession(_ context.Context, store beads.SessionStore, body sessionCreateBody, providerName string) (*SessionCreateOutput, error) { cfg := s.state.Config() if cfg == nil { - return nil, huma.Error503ServiceUnavailable("city config not loaded yet") + return nil, apierr.ServiceUnavailable.Msg("city config not loaded yet") } resolved, err := config.ResolveProvider( &config.Agent{Provider: providerName}, @@ -241,26 +241,26 @@ func (s *Server) humaCreateProviderSession(_ context.Context, store beads.Sessio ) if err != nil { if errors.Is(err, config.ErrProviderNotInPATH) { - return nil, huma.Error503ServiceUnavailable(err.Error()) + return nil, apierr.ServiceUnavailable.Msg(err.Error()) } if errors.Is(err, config.ErrProviderNotFound) { - return nil, huma.Error404NotFound("provider '" + providerName + "' not found") + return nil, apierr.ProviderNotFound.Msg("provider '" + providerName + "' not found") } - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } var optMeta map[string]string if len(body.Options) > 0 && len(resolved.OptionsSchema) == 0 { - return nil, huma.Error400BadRequest("provider '" + providerName + "' does not accept options") + return nil, apierr.InvalidRequest.Msg("provider '" + providerName + "' does not accept options") } if len(resolved.OptionsSchema) > 0 { var optErr error _, optMeta, optErr = config.ResolveOptions(resolved.OptionsSchema, body.Options, resolved.EffectiveDefaults) if optErr != nil { if errors.Is(optErr, config.ErrUnknownOption) { - return nil, huma.Error400BadRequest(optErr.Error()) + return nil, apierr.InvalidRequest.Msg(optErr.Error()) } - return nil, huma.Error400BadRequest(optErr.Error()) + return nil, apierr.InvalidRequest.Msg(optErr.Error()) } } @@ -270,10 +270,10 @@ func (s *Server) humaCreateProviderSession(_ context.Context, store beads.Sessio title = resolved.Name } if body.Async && strings.TrimSpace(body.Message) != "" { - return nil, huma.Error400BadRequest("message is not supported with async session creation; create the session, then POST /v0/session/{id}/messages") + return nil, apierr.InvalidRequest.Msg("message is not supported with async session creation; create the session, then POST /v0/session/{id}/messages") } if body.Async { - return nil, huma.Error400BadRequest("async session creation is only supported for configured agent templates") + return nil, apierr.InvalidRequest.Msg("async session creation is only supported for configured agent templates") } workDir := s.state.CityPath() @@ -284,21 +284,21 @@ func (s *Server) humaCreateProviderSession(_ context.Context, store beads.Sessio } mcpIdentity, err := providerSessionMCPIdentity(resolved.Name, alias) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } transport, err := providerSessionTransport(resolved, s.state.SessionProvider()) if err != nil { - return nil, huma.Error503ServiceUnavailable(err.Error()) + return nil, apierr.ServiceUnavailable.Msg(err.Error()) } launchCommand, err := config.BuildProviderLaunchCommand(s.state.CityPath(), resolved, body.Options, transport) if err != nil { - return nil, huma.Error400BadRequest(err.Error()) + return nil, apierr.InvalidRequest.Msg(err.Error()) } command := launchCommand.Command mcpServers, err := s.providerSessionMCPServers(resolved.Name, mcpIdentity, workDir, transport) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } extraMeta := sessionTemplateOverridesMetadata(body.Options, body.Message) if extraMeta == nil { @@ -308,17 +308,17 @@ func (s *Server) humaCreateProviderSession(_ context.Context, store beads.Sessio if transport == "acp" { extraMeta, err = session.WithStoredMCPMetadata(extraMeta, mcpIdentity, mcpServers) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } } reqID, reqIDErr := newRequestID() if reqIDErr != nil { - return nil, huma.Error500InternalServerError(reqIDErr.Error()) + return nil, apierr.Internal.Msg(reqIDErr.Error()) } eventCursor, cursorErr := s.currentCityEventCursor() if cursorErr != nil { - return nil, huma.Error500InternalServerError(cursorErr.Error()) + return nil, apierr.Internal.Msg(cursorErr.Error()) } go func() { defer s.recoverAsRequestFailed(reqID, RequestOperationSessionCreate) @@ -396,7 +396,7 @@ type sessionTranscriptGetResponse struct { func (s *Server) humaHandleSessionPatch(_ context.Context, input *SessionPatchInput) (*IndexOutput[sessionResponse], error) { store := s.state.SessionsBeadStore() if store.Store == nil { - return nil, huma.Error503ServiceUnavailable("no bead store configured") + return nil, apierr.ServiceUnavailable.Msg("no bead store configured") } id, err := s.resolveSessionIDWithConfig(store.Store, input.ID) @@ -413,7 +413,7 @@ func (s *Server) humaHandleSessionPatch(_ context.Context, input *SessionPatchIn aliasPtr := input.Body.Alias if titlePtr == nil && aliasPtr == nil { - return nil, huma.Error422UnprocessableEntity("at least one of 'title' or 'alias' is required") + return nil, apierr.ValidationFailed.Msg("at least one of 'title' or 'alias' is required") } b, err := store.Get(id) @@ -421,7 +421,7 @@ func (s *Server) humaHandleSessionPatch(_ context.Context, input *SessionPatchIn return nil, humaStoreError(err) } if !session.IsSessionBeadOrRepairable(b) { - return nil, huma.Error400BadRequest(id + " is not a session") + return nil, apierr.InvalidRequest.Msg(id + " is not a session") } session.RepairEmptyType(store.Store, &b) @@ -431,7 +431,7 @@ func (s *Server) humaHandleSessionPatch(_ context.Context, input *SessionPatchIn } if aliasPtr != nil { if strings.TrimSpace(session.InfoFromPersistedBead(b).AgentName) != "" { - return nil, huma.Error403Forbidden("forbidden: alias is controller-managed for this session") + return nil, apierr.Forbidden.Msg("forbidden: alias is controller-managed for this session") } if lockErr := session.WithCitySessionAliasLock(s.state.CityPath(), *aliasPtr, func() error { if avErr := session.EnsureAliasAvailableWithConfig(store.Store, s.state.Config(), *aliasPtr, id); avErr != nil { @@ -465,7 +465,7 @@ func (s *Server) humaHandleSessionPermissionMode(_ context.Context, input *Sessi func (s *Server) updateSessionPermissionMode(idRef string, body SessionPermissionModeBody) (*IndexOutput[sessionResponse], error) { store := s.state.SessionsBeadStore() if store.Store == nil { - return nil, huma.Error503ServiceUnavailable("no bead store configured") + return nil, apierr.ServiceUnavailable.Msg("no bead store configured") } id, err := s.resolveSessionIDAllowClosedWithConfig(store.Store, idRef) @@ -477,7 +477,7 @@ func (s *Server) updateSessionPermissionMode(idRef string, body SessionPermissio return nil, humaStoreError(err) } if !session.IsSessionBeadOrRepairable(b) { - return nil, huma.Error400BadRequest(id + " is not a session") + return nil, apierr.InvalidRequest.Msg(id + " is not a session") } session.RepairEmptyType(store.Store, &b) @@ -487,36 +487,36 @@ func (s *Server) updateSessionPermissionMode(idRef string, body SessionPermissio return nil, humaSessionManagerError(err) } if info.Closed { - return nil, huma.Error409Conflict("conflict: session is closed") + return nil, apierr.SessionConflict.Msg("conflict: session is closed") } if session.IsTemplateOverrideRuntimeActive(info.State) { - return nil, huma.Error409Conflict("conflict: session is running; permission_mode changes use schema options and apply only before the next launch") + return nil, apierr.SessionConflict.Msg("conflict: session is running; permission_mode changes use schema options and apply only before the next launch") } cfg := s.state.Config() if cfg == nil { - return nil, huma.Error503ServiceUnavailable("city config not loaded yet") + return nil, apierr.ServiceUnavailable.Msg("city config not loaded yet") } agent, agentFound := findAgent(cfg, info.Template) if session.UseAgentTemplateForProviderResolution(legacySessionKind(b.Metadata), b.Metadata, info.Provider, agent.Provider, agentFound) { if !agentFound { - return nil, huma.Error409Conflict("conflict: session agent template no longer resolves; restore the template or recreate the session before changing schema options") + return nil, apierr.SessionConflict.Msg("conflict: session agent template no longer resolves; restore the template or recreate the session before changing schema options") } } resolved, resolveErr := resolveProviderForSessionOptions(info, b.Metadata, cfg) if resolved == nil { if resolveErr != nil { - return nil, huma.Error409Conflict("conflict: session provider no longer resolves: " + resolveErr.Error()) + return nil, apierr.SessionConflict.Msg("conflict: session provider no longer resolves: " + resolveErr.Error()) } - return nil, huma.Error501NotImplemented("unsupported: session provider does not accept schema options") + return nil, apierr.NotImplemented.Msg("unsupported: session provider does not accept schema options") } if !providerHasOption(resolved.OptionsSchema, sessionPermissionModeOptionKey) { - return nil, huma.Error501NotImplemented("unsupported: session provider does not define permission_mode in options_schema") + return nil, apierr.NotImplemented.Msg("unsupported: session provider does not define permission_mode in options_schema") } mode := strings.TrimSpace(body.PermissionMode) if _, optErr := config.ResolveExplicitOptions(resolved.OptionsSchema, map[string]string{sessionPermissionModeOptionKey: mode}); optErr != nil { - return nil, huma.Error400BadRequest(optErr.Error()) + return nil, apierr.InvalidRequest.Msg(optErr.Error()) } if _, err := mgr.UpdateTemplateOverrides(id, map[string]string{sessionPermissionModeOptionKey: mode}); err != nil { @@ -551,7 +551,7 @@ func providerHasOption(schema []config.ProviderOption, key string) bool { func (s *Server) humaHandleSessionSubmit(_ context.Context, input *SessionSubmitInput) (*SessionSubmitOutput, error) { store := s.state.SessionsBeadStore() if store.Store == nil { - return nil, huma.Error503ServiceUnavailable("no bead store configured") + return nil, apierr.ServiceUnavailable.Msg("no bead store configured") } intent := input.Body.Intent @@ -561,11 +561,11 @@ func (s *Server) humaHandleSessionSubmit(_ context.Context, input *SessionSubmit reqID, reqIDErr := newRequestID() if reqIDErr != nil { - return nil, huma.Error500InternalServerError(reqIDErr.Error()) + return nil, apierr.Internal.Msg(reqIDErr.Error()) } eventCursor, cursorErr := s.currentCityEventCursor() if cursorErr != nil { - return nil, huma.Error500InternalServerError(cursorErr.Error()) + return nil, apierr.Internal.Msg(cursorErr.Error()) } message := input.Body.Message sessionTarget := input.ID @@ -598,16 +598,16 @@ func (s *Server) humaHandleSessionSubmit(_ context.Context, input *SessionSubmit func (s *Server) humaHandleSessionMessage(_ context.Context, input *SessionMessageInput) (*SessionMessageOutput, error) { store := s.state.SessionsBeadStore() if store.Store == nil { - return nil, huma.Error503ServiceUnavailable("no bead store configured") + return nil, apierr.ServiceUnavailable.Msg("no bead store configured") } reqID, reqIDErr := newRequestID() if reqIDErr != nil { - return nil, huma.Error500InternalServerError(reqIDErr.Error()) + return nil, apierr.Internal.Msg(reqIDErr.Error()) } eventCursor, cursorErr := s.currentCityEventCursor() if cursorErr != nil { - return nil, huma.Error500InternalServerError(cursorErr.Error()) + return nil, apierr.Internal.Msg(cursorErr.Error()) } message := input.Body.Message sessionTarget := input.ID @@ -699,7 +699,7 @@ func (s *Server) humaHandleSessionMessage(_ context.Context, input *SessionMessa func (s *Server) humaHandleSessionStop(_ context.Context, input *SessionIDInput) (*OKWithIDResponse, error) { store := s.state.SessionsBeadStore() if store.Store == nil { - return nil, huma.Error503ServiceUnavailable("no bead store configured") + return nil, apierr.ServiceUnavailable.Msg("no bead store configured") } id, err := s.resolveSessionIDWithConfig(store.Store, input.ID) @@ -724,7 +724,7 @@ func (s *Server) humaHandleSessionStop(_ context.Context, input *SessionIDInput) func (s *Server) humaHandleSessionKill(_ context.Context, input *SessionIDInput) (*OKWithIDResponse, error) { store := s.state.SessionsBeadStore() if store.Store == nil { - return nil, huma.Error503ServiceUnavailable("no bead store configured") + return nil, apierr.ServiceUnavailable.Msg("no bead store configured") } id, err := s.resolveSessionIDWithConfig(store.Store, input.ID) @@ -755,7 +755,7 @@ func (s *Server) humaHandleSessionKill(_ context.Context, input *SessionIDInput) func (s *Server) humaHandleSessionRespond(_ context.Context, input *SessionRespondInput) (*SessionRespondOutput, error) { store := s.state.SessionsBeadStore() if store.Store == nil { - return nil, huma.Error503ServiceUnavailable("no bead store configured") + return nil, apierr.ServiceUnavailable.Msg("no bead store configured") } id, err := s.resolveSessionIDWithConfig(store.Store, input.ID) @@ -787,7 +787,7 @@ func (s *Server) humaHandleSessionRespond(_ context.Context, input *SessionRespo func (s *Server) humaHandleSessionSuspend(ctx context.Context, input *SessionIDInput) (*OKResponse, error) { store := s.state.SessionsBeadStore() if store.Store == nil { - return nil, huma.Error503ServiceUnavailable("no bead store configured") + return nil, apierr.ServiceUnavailable.Msg("no bead store configured") } mgr := s.sessionManager(store.Store) @@ -810,7 +810,7 @@ func (s *Server) humaHandleSessionSuspend(ctx context.Context, input *SessionIDI func (s *Server) humaHandleSessionClose(ctx context.Context, input *SessionCloseInput) (*OKResponse, error) { store := s.state.SessionsBeadStore() if store.Store == nil { - return nil, huma.Error503ServiceUnavailable("no bead store configured") + return nil, apierr.ServiceUnavailable.Msg("no bead store configured") } id, err := s.resolveSessionIDWithConfig(store.Store, input.ID) @@ -822,7 +822,7 @@ func (s *Server) humaHandleSessionClose(ctx context.Context, input *SessionClose strings.TrimSpace(b.Metadata[apiNamedSessionMetadataKey]) == "true" && strings.TrimSpace(b.Metadata[apiNamedSessionModeKey]) == "always" && strings.Contains(strings.TrimSpace(b.Metadata[apiNamedSessionIdentityKey]), "/") { - return nil, huma.Error409Conflict("configured always-on named sessions cannot be closed while config-managed") + return nil, apierr.SessionConflict.Msg("configured always-on named sessions cannot be closed while config-managed") } handle, err := s.workerHandleForSession(store.Store, id) if err != nil { @@ -842,7 +842,7 @@ func (s *Server) humaHandleSessionClose(ctx context.Context, input *SessionClose if input.Delete { if err := deleteSessionBeadAfterClose(store.Store, id); err != nil { log.Printf("gc api: deleting bead after close %s: %v", id, err) - return nil, huma.Error500InternalServerError("closed but delete failed: " + err.Error()) + return nil, apierr.Internal.Msg("closed but delete failed: " + err.Error()) } } @@ -858,7 +858,7 @@ func (s *Server) humaHandleSessionClose(ctx context.Context, input *SessionClose func (s *Server) humaHandleSessionWake(ctx context.Context, input *SessionIDInput) (*OKWithIDResponse, error) { store := s.state.SessionsBeadStore() if store.Store == nil { - return nil, huma.Error503ServiceUnavailable("no bead store configured") + return nil, apierr.ServiceUnavailable.Msg("no bead store configured") } id, err := s.resolveSessionIDMaterializingNamedWithContext(ctx, store.Store, input.ID) @@ -871,16 +871,16 @@ func (s *Server) humaHandleSessionWake(ctx context.Context, input *SessionIDInpu return nil, humaStoreError(err) } if !session.IsSessionBeadOrRepairable(b) { - return nil, huma.Error400BadRequest(id + " is not a session") + return nil, apierr.InvalidRequest.Msg(id + " is not a session") } session.RepairEmptyType(store.Store, &b) if b.Status == "closed" { - return nil, huma.Error409Conflict("session " + id + " is closed") + return nil, apierr.SessionConflict.Msg("session " + id + " is closed") } nudgeIDs, err := session.WakeSession(store.Store, b, time.Now().UTC()) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } // Nudge withdrawal reads the nudges class, so it sources the typed // NudgesBeadStore (identity to the work store until that class relocates). @@ -916,7 +916,7 @@ func (s *Server) humaHandleSessionWake(ctx context.Context, input *SessionIDInpu func (s *Server) humaHandleSessionRename(_ context.Context, input *SessionRenameInput) (*IndexOutput[sessionResponse], error) { store := s.state.SessionsBeadStore() if store.Store == nil { - return nil, huma.Error503ServiceUnavailable("no bead store configured") + return nil, apierr.ServiceUnavailable.Msg("no bead store configured") } id, err := s.resolveSessionIDWithConfig(store.Store, input.ID) @@ -930,7 +930,7 @@ func (s *Server) humaHandleSessionRename(_ context.Context, input *SessionRename return nil, humaStoreError(err) } if !session.IsSessionBeadOrRepairable(b) { - return nil, huma.Error400BadRequest(id + " is not a session") + return nil, apierr.InvalidRequest.Msg(id + " is not a session") } session.RepairEmptyType(store.Store, &b) diff --git a/internal/api/huma_handlers_sessions_query.go b/internal/api/huma_handlers_sessions_query.go index 8579697e65..47458a2cb3 100644 --- a/internal/api/huma_handlers_sessions_query.go +++ b/internal/api/huma_handlers_sessions_query.go @@ -7,7 +7,7 @@ import ( "log" "strings" - "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/beads" "github.com/gastownhall/gascity/internal/runtime" "github.com/gastownhall/gascity/internal/session" @@ -23,14 +23,14 @@ import ( func (s *Server) humaHandleSessionList(_ context.Context, input *SessionListInput) (*ListOutput[sessionResponse], error) { store := s.state.SessionsBeadStore() if store.Store == nil { - return nil, huma.Error503ServiceUnavailable("no bead store configured") + return nil, apierr.ServiceUnavailable.Msg("no bead store configured") } mgr := s.sessionManager(store.Store) cfg := s.state.Config() all, partialErrors, err := sessionReadModelRows(store.Store) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } listResult := mgr.ListFullFromBeads(all, input.State, input.Template) sessions := listResult.Sessions @@ -107,7 +107,7 @@ func (s *Server) humaHandleSessionList(_ context.Context, input *SessionListInpu func (s *Server) humaHandleSessionGet(_ context.Context, input *SessionGetInput) (*IndexOutput[sessionResponse], error) { store := s.state.SessionsBeadStore() if store.Store == nil { - return nil, huma.Error503ServiceUnavailable("no bead store configured") + return nil, apierr.ServiceUnavailable.Msg("no bead store configured") } mgr := s.sessionManager(store.Store) cfg := s.state.Config() @@ -138,7 +138,7 @@ func (s *Server) humaHandleSessionGet(_ context.Context, input *SessionGetInput) func (s *Server) humaHandleSessionTranscript(_ context.Context, input *SessionTranscriptInput) (*IndexOutput[sessionTranscriptGetResponse], error) { store := s.state.SessionsBeadStore() if store.Store == nil { - return nil, huma.Error503ServiceUnavailable("no bead store configured") + return nil, apierr.ServiceUnavailable.Msg("no bead store configured") } id, err := s.resolveSessionIDAllowClosedWithConfig(store.Store, input.ID) @@ -169,7 +169,7 @@ func (s *Server) humaHandleSessionTranscript(_ context.Context, input *SessionTr after := input.After if before != "" && after != "" { - return nil, huma.Error422UnprocessableEntity("before and after are mutually exclusive") + return nil, apierr.ValidationFailed.Msg("before and after are mutually exclusive") } if wantRaw { @@ -183,7 +183,7 @@ func (s *Server) humaHandleSessionTranscript(_ context.Context, input *SessionTr rawSess, err = sessionlog.ReadProviderFileRaw(info.Provider, path, tail) } if err != nil { - return nil, huma.Error500InternalServerError("reading session log: " + err.Error()) + return nil, apierr.Internal.Msg("reading session log: " + err.Error()) } return &IndexOutput[sessionTranscriptGetResponse]{ Index: s.latestIndex(), @@ -208,7 +208,7 @@ func (s *Server) humaHandleSessionTranscript(_ context.Context, input *SessionTr sess, err = sessionlog.ReadProviderFile(info.Provider, path, tail) } if err != nil { - return nil, huma.Error500InternalServerError("reading session log: " + err.Error()) + return nil, apierr.Internal.Msg("reading session log: " + err.Error()) } turns := make([]outputTurn, 0, len(sess.Messages)) @@ -248,7 +248,7 @@ func (s *Server) humaHandleSessionTranscript(_ context.Context, input *SessionTr if info.State == session.StateActive && s.state.SessionProvider().IsRunning(info.SessionName) { output, peekErr := s.state.SessionProvider().Peek(info.SessionName, 100) if peekErr != nil { - return nil, huma.Error500InternalServerError(peekErr.Error()) + return nil, apierr.Internal.Msg(peekErr.Error()) } turns := []outputTurn{} if output != "" { @@ -285,7 +285,7 @@ func (s *Server) humaHandleSessionTranscript(_ context.Context, input *SessionTr func (s *Server) humaHandleSessionPending(_ context.Context, input *SessionIDInput) (*IndexOutput[sessionPendingResponse], error) { store := s.state.SessionsBeadStore() if store.Store == nil { - return nil, huma.Error503ServiceUnavailable("no bead store configured") + return nil, apierr.ServiceUnavailable.Msg("no bead store configured") } id, err := s.resolveSessionIDWithConfig(store.Store, input.ID) @@ -345,13 +345,13 @@ type cityPendingProbe struct { func (s *Server) humaHandleCityPending(_ context.Context, _ *CityPendingInput) (*ListOutput[cityPendingEntry], error) { store := s.state.SessionsBeadStore() if store.Store == nil { - return nil, huma.Error503ServiceUnavailable("no bead store configured") + return nil, apierr.ServiceUnavailable.Msg("no bead store configured") } mgr := s.sessionManager(store.Store) all, partialErrors, err := sessionReadModelRows(store.Store) if err != nil { - return nil, huma.Error500InternalServerError(err.Error()) + return nil, apierr.Internal.Msg(err.Error()) } // Active sessions can be awaiting a human decision — and so can legacy // empty-state ("none") beads, which the codebase treats as active for @@ -427,7 +427,7 @@ func (s *Server) humaHandleCityPending(_ context.Context, _ *CityPendingInput) ( func (s *Server) humaHandleSessionAgentList(_ context.Context, input *SessionIDInput) (*IndexOutput[sessionAgentListResponse], error) { store := s.state.SessionsBeadStore() if store.Store == nil { - return nil, huma.Error503ServiceUnavailable("no bead store configured") + return nil, apierr.ServiceUnavailable.Msg("no bead store configured") } id, err := s.resolveSessionIDAllowClosedWithConfig(store.Store, input.ID) @@ -450,7 +450,7 @@ func (s *Server) humaHandleSessionAgentList(_ context.Context, input *SessionIDI mappings, err := sessionlog.FindAgentMappings(logPath) if err != nil { log.Printf("gc api: session %s agent mapping failed for %s: %v", id, logPath, err) - return nil, huma.Error500InternalServerError("failed to list agents") + return nil, apierr.Internal.Msg("failed to list agents") } if mappings == nil { mappings = []sessionlog.AgentMapping{} @@ -468,7 +468,7 @@ func (s *Server) humaHandleSessionAgentList(_ context.Context, input *SessionIDI func (s *Server) humaHandleSessionAgentGet(_ context.Context, input *SessionAgentGetInput) (*IndexOutput[sessionAgentGetResponse], error) { store := s.state.SessionsBeadStore() if store.Store == nil { - return nil, huma.Error503ServiceUnavailable("no bead store configured") + return nil, apierr.ServiceUnavailable.Msg("no bead store configured") } id, err := s.resolveSessionIDAllowClosedWithConfig(store.Store, input.ID) @@ -477,10 +477,10 @@ func (s *Server) humaHandleSessionAgentGet(_ context.Context, input *SessionAgen } if input.AgentID == "" { - return nil, huma.Error400BadRequest("agentId is required") + return nil, apierr.InvalidRequest.Msg("agentId is required") } if err := sessionlog.ValidateAgentID(input.AgentID); err != nil { - return nil, huma.Error400BadRequest(err.Error()) + return nil, apierr.InvalidRequest.Msg(err.Error()) } mgr := s.sessionManager(store.Store) @@ -489,15 +489,15 @@ func (s *Server) humaHandleSessionAgentGet(_ context.Context, input *SessionAgen return nil, humaSessionManagerError(err) } if logPath == "" { - return nil, huma.Error404NotFound("no transcript found for session " + id) + return nil, apierr.SessionNotFound.Msg("no transcript found for session " + id) } agentSession, err := sessionlog.ReadAgentSession(logPath, input.AgentID) if err != nil { if errors.Is(err, sessionlog.ErrAgentNotFound) { - return nil, huma.Error404NotFound("agent not found") + return nil, apierr.AgentNotFound.Msg("agent not found") } - return nil, huma.Error500InternalServerError("failed to read agent transcript") + return nil, apierr.Internal.Msg("failed to read agent transcript") } return &IndexOutput[sessionAgentGetResponse]{ diff --git a/internal/api/huma_handlers_sessions_stream.go b/internal/api/huma_handlers_sessions_stream.go index 78bd4e5bef..cf8678c9e8 100644 --- a/internal/api/huma_handlers_sessions_stream.go +++ b/internal/api/huma_handlers_sessions_stream.go @@ -7,6 +7,7 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/sse" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/session" "github.com/gastownhall/gascity/internal/worker" ) @@ -18,7 +19,7 @@ import ( func (s *Server) resolveSessionStream(ctx context.Context, input *SessionStreamInput) (*sessionStreamState, error) { store := s.state.SessionsBeadStore() if store.Store == nil { - return nil, huma.Error503ServiceUnavailable("no bead store configured") + return nil, apierr.ServiceUnavailable.Msg("no bead store configured") } id, err := s.resolveSessionIDAllowClosedWithConfig(store.Store, input.ID) @@ -43,7 +44,7 @@ func (s *Server) resolveSessionStream(ctx context.Context, input *SessionStreamI history, historyErr := handle.History(worker.WithoutOperationEvents(ctx), historyReq) hasHistory := historyErr == nil && history != nil if historyErr != nil && !errors.Is(historyErr, worker.ErrHistoryUnavailable) { - return nil, huma.Error500InternalServerError("reading session history: " + historyErr.Error()) + return nil, apierr.Internal.Msg("reading session history: " + historyErr.Error()) } state, stateErr := handle.State(ctx) @@ -52,7 +53,7 @@ func (s *Server) resolveSessionStream(ctx context.Context, input *SessionStreamI } running := workerPhaseHasLiveOutput(state.Phase) if !hasHistory && !running { - return nil, huma.Error404NotFound("session " + id + " has no live output") + return nil, apierr.SessionNotFound.Msg("session " + id + " has no live output") } return &sessionStreamState{ diff --git a/internal/api/huma_handlers_sling.go b/internal/api/huma_handlers_sling.go index 2f573ddde1..282e8c780e 100644 --- a/internal/api/huma_handlers_sling.go +++ b/internal/api/huma_handlers_sling.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" ) // SlingOutput is the Huma response for POST /v0/sling. @@ -31,7 +32,7 @@ func (s *Server) humaHandleSling(ctx context.Context, input *SlingInput) (*Sling } if body.Target == "" { - return nil, huma.Error400BadRequest("target agent or pool is required") + return nil, apierr.InvalidRequest.Msg("target agent or pool is required") } body.ScopeKind = strings.TrimSpace(body.ScopeKind) @@ -45,13 +46,13 @@ func (s *Server) humaHandleSling(ctx context.Context, input *SlingInput) (*Sling } if body.Bead == "" && body.Formula == "" { - return nil, huma.Error400BadRequest("bead or formula is required") + return nil, apierr.InvalidRequest.Msg("bead or formula is required") } if body.Bead != "" && body.Formula != "" { - return nil, huma.Error400BadRequest("bead and formula are mutually exclusive") + return nil, apierr.InvalidRequest.Msg("bead and formula are mutually exclusive") } if body.Bead != "" && body.AttachedBeadID != "" { - return nil, huma.Error400BadRequest("bead and attached_bead_id are mutually exclusive") + return nil, apierr.InvalidRequest.Msg("bead and attached_bead_id are mutually exclusive") } workflowLaunchOptions := body.AttachedBeadID != "" || @@ -65,16 +66,16 @@ func (s *Server) humaHandleSling(ctx context.Context, input *SlingInput) (*Sling agentCfg.EffectiveDefaultSlingFormula() != "" && (len(body.Vars) > 0 || body.Title != "" || body.ScopeKind != "" || body.ScopeRef != "") if body.Formula == "" && body.AttachedBeadID != "" { - return nil, huma.Error400BadRequest("formula is required when attached_bead_id is provided") + return nil, apierr.InvalidRequest.Msg("formula is required when attached_bead_id is provided") } if body.Formula == "" && workflowLaunchOptions && !defaultFormulaLaunch { - return nil, huma.Error400BadRequest("formula or target default formula is required when vars, title, or scope are provided") + return nil, apierr.InvalidRequest.Msg("formula or target default formula is required when vars, title, or scope are provided") } if (body.ScopeKind == "") != (body.ScopeRef == "") { - return nil, huma.Error400BadRequest("scope_kind and scope_ref must be provided together") + return nil, apierr.InvalidRequest.Msg("scope_kind and scope_ref must be provided together") } if body.ScopeKind != "" && body.ScopeKind != "city" && body.ScopeKind != "rig" { - return nil, huma.Error400BadRequest("scope_kind must be 'city' or 'rig'") + return nil, apierr.InvalidRequest.Msg("scope_kind must be 'city' or 'rig'") } if body.ScopeKind == "rig" && body.ScopeRef != "" { if agentCfg.Dir != body.ScopeRef { @@ -82,10 +83,10 @@ func (s *Server) humaHandleSling(ctx context.Context, input *SlingInput) (*Sling if agentCfg.Dir == "" { msg = "scope_ref " + body.ScopeRef + " requires a rig-scoped target; resolved target " + body.Target + " is city-scoped" } - return nil, huma.Error400BadRequest(msg) + return nil, apierr.InvalidRequest.Msg(msg) } if body.Rig != "" && body.Rig != body.ScopeRef { - return nil, huma.Error400BadRequest("rig " + body.Rig + " conflicts with scope_ref " + body.ScopeRef) + return nil, apierr.InvalidRequest.Msg("rig " + body.Rig + " conflicts with scope_ref " + body.ScopeRef) } } @@ -95,52 +96,32 @@ func (s *Server) humaHandleSling(ctx context.Context, input *SlingInput) (*Sling return nil, huma.Error404NotFound(message) } // Source-workflow conflict: render the rich 409 shape the CLI and - // dashboard use to offer a "force or clean up" decision. Huma's - // generic Error4xx collapses everything into Problem Details with - // only a string detail, so we build the Problem Details error - // manually with structured extensions. + // dashboard use to offer a "force or clean up" decision. The structured + // Errors[] entries (source_bead_id, blocking_workflow_ids, hint) are the + // wire contract those clients read; the catalog constructor preserves + // them and adds the stable type/code. if conflict != nil && status == http.StatusConflict { storeRef := s.slingStoreRef(body.Rig, agentCfg, slingStoreBeadID(body)) hint := sourceWorkflowCleanupHint(conflict.SourceBeadID, storeRef) - return nil, &huma.ErrorModel{ - Status: http.StatusConflict, - Title: http.StatusText(http.StatusConflict), - Detail: message, - Errors: []*huma.ErrorDetail{ - {Location: "body.source_bead_id", Value: conflict.SourceBeadID}, - {Location: "body.blocking_workflow_ids", Value: conflict.WorkflowIDs}, - {Location: "body.hint", Value: hint}, - }, - } + return nil, apierr.SlingSourceWorkflowConflict.With(message, + &huma.ErrorDetail{Location: "body.source_bead_id", Value: conflict.SourceBeadID}, + &huma.ErrorDetail{Location: "body.blocking_workflow_ids", Value: conflict.WorkflowIDs}, + &huma.ErrorDetail{Location: "body.hint", Value: hint}, + ) } if status >= http.StatusInternalServerError { - return nil, huma.Error500InternalServerError(message) + return nil, apierr.Internal.Msg(message) } if code == "missing_bead" { - return nil, &huma.ErrorModel{ - Type: slingMissingBeadProblemType, - Status: http.StatusBadRequest, - Title: http.StatusText(http.StatusBadRequest), - Detail: message, - } + return nil, apierr.SlingMissingBead.Msg(message) } if code == "cross_rig" { - return nil, &huma.ErrorModel{ - Type: slingCrossRigProblemType, - Status: http.StatusBadRequest, - Title: http.StatusText(http.StatusBadRequest), - Detail: message, - } + return nil, apierr.SlingCrossRig.Msg(message) } if code == "cross_store" { - return nil, &huma.ErrorModel{ - Type: slingCrossStoreRouteProblemType, - Status: http.StatusBadRequest, - Title: http.StatusText(http.StatusBadRequest), - Detail: message, - } + return nil, apierr.SlingCrossStoreRoute.Msg(message) } - return nil, huma.Error400BadRequest(message) + return nil, apierr.InvalidRequest.Msg(message) } return &SlingOutput{ diff --git a/internal/api/huma_handlers_supervisor.go b/internal/api/huma_handlers_supervisor.go index 34eba74bb3..fcfdc40a8e 100644 --- a/internal/api/huma_handlers_supervisor.go +++ b/internal/api/huma_handlers_supervisor.go @@ -16,6 +16,7 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humago" + "github.com/gastownhall/gascity/internal/api/apierr" "github.com/gastownhall/gascity/internal/cityinit" "github.com/gastownhall/gascity/internal/citylayout" "github.com/gastownhall/gascity/internal/events" @@ -307,11 +308,11 @@ func packsLockSHA256(cityPath string) string { func (sm *SupervisorMux) humaHandleReadiness(ctx context.Context, input *SupervisorReadinessInput) (*SupervisorReadinessOutput, error) { items, err := parseRequestedReadinessItems(input.Items, "items", defaultReadinessItems, supportedReadiness) if err != nil { - return nil, huma.Error400BadRequest("invalid: " + err.Error()) + return nil, apierr.InvalidRequest.Msg("invalid: " + err.Error()) } resp, err := buildReadinessResponse(ctx, items, input.Fresh) if err != nil { - return nil, huma.Error500InternalServerError("internal: " + err.Error()) + return nil, apierr.Internal.Msg("internal: " + err.Error()) } out := &SupervisorReadinessOutput{} out.Body = resp @@ -321,11 +322,11 @@ func (sm *SupervisorMux) humaHandleReadiness(ctx context.Context, input *Supervi func (sm *SupervisorMux) humaHandleProviderReadiness(ctx context.Context, input *SupervisorProviderReadinessInput) (*SupervisorProviderReadinessOutput, error) { providers, err := parseRequestedReadinessItems(input.Providers, "providers", defaultProviderReadinessItems, supportedProviderReadiness) if err != nil { - return nil, huma.Error400BadRequest("invalid: " + err.Error()) + return nil, apierr.InvalidRequest.Msg("invalid: " + err.Error()) } resp, err := buildReadinessResponse(ctx, providers, input.Fresh) if err != nil { - return nil, huma.Error500InternalServerError("internal: " + err.Error()) + return nil, apierr.Internal.Msg("internal: " + err.Error()) } providerResp := providerReadinessResponse{ Providers: make(map[string]providerReadiness, len(providers)), @@ -361,7 +362,7 @@ func (sm *SupervisorMux) humaHandleCityCreate(ctx context.Context, input *Superv if !filepath.IsAbs(dir) { home, err := os.UserHomeDir() if err != nil { - return nil, huma.Error500InternalServerError(fmt.Sprintf("internal: resolving home dir: %v", err)) + return nil, apierr.Internal.Msg(fmt.Sprintf("internal: resolving home dir: %v", err)) } dir = filepath.Join(home, dir) } @@ -372,28 +373,28 @@ func (sm *SupervisorMux) humaHandleCityCreate(ctx context.Context, input *Superv // in test configurations that build a SupervisorMux without an // initializer. if cityDirAlreadyInitialized(dir) { - return nil, huma.Error409Conflict("conflict: city already initialized at " + dir) + return nil, apierr.ConflictWrongState.Msg("conflict: city already initialized at " + dir) } if sm.initializer == nil { - return nil, huma.Error501NotImplemented("city creation is not available in this supervisor (no initializer wired)") + return nil, apierr.NotImplemented.Msg("city creation is not available in this supervisor (no initializer wired)") } reqID, err := newRequestID() if err != nil { - return nil, huma.Error500InternalServerError(fmt.Sprintf("generating request ID: %v", err)) + return nil, apierr.Internal.Msg(fmt.Sprintf("generating request ID: %v", err)) } eventCursor, cursorErr := sm.currentSupervisorEventCursor() if cursorErr != nil { - return nil, huma.Error500InternalServerError(cursorErr.Error()) + return nil, apierr.Internal.Msg(cursorErr.Error()) } pendingStored := false if store, ok := sm.resolver.(PendingRequestStore); ok { if err := store.StorePendingRequestID(dir, reqID); err != nil { if errors.Is(err, ErrPendingRequestExists) { - return nil, huma.Error409Conflict("conflict: city initialization already in progress at " + dir) + return nil, apierr.OperationInProgress.Msg("conflict: city initialization already in progress at " + dir) } - return nil, huma.Error500InternalServerError(fmt.Sprintf("storing pending request ID: %v", err)) + return nil, apierr.Internal.Msg(fmt.Sprintf("storing pending request ID: %v", err)) } pendingStored = true } @@ -409,12 +410,12 @@ func (sm *SupervisorMux) humaHandleCityCreate(ctx context.Context, input *Superv switch { case errors.Is(scaffoldErr, cityinit.ErrAlreadyInitialized): sm.clearPendingCityRequestID(dir, pendingStored) - return nil, huma.Error409Conflict("conflict: city already initialized at " + dir) + return nil, apierr.ConflictWrongState.Msg("conflict: city already initialized at " + dir) case errors.Is(scaffoldErr, cityinit.ErrInvalidDirectory), errors.Is(scaffoldErr, cityinit.ErrInvalidProvider), errors.Is(scaffoldErr, cityinit.ErrInvalidBootstrapProfile): sm.clearPendingCityRequestID(dir, pendingStored) - return nil, huma.Error422UnprocessableEntity(scaffoldErr.Error()) + return nil, apierr.ValidationFailed.Msg(scaffoldErr.Error()) case errors.Is(scaffoldErr, cityinit.ErrPostRegisterFailure): failureReqID := reqID if consumedReqID, ok := sm.consumePendingCityRequestID(dir, pendingStored); ok { @@ -424,7 +425,7 @@ func (sm *SupervisorMux) humaHandleCityCreate(ctx context.Context, input *Superv postRegisterFailed = true case scaffoldErr != nil: sm.clearPendingCityRequestID(dir, pendingStored) - return nil, huma.Error500InternalServerError(scaffoldErr.Error()) + return nil, apierr.Internal.Msg(scaffoldErr.Error()) } if !pendingStored && !postRegisterFailed { @@ -531,20 +532,20 @@ func emitCityCreateFailed(resolver CityResolver, requestID string, result *cityi // - any other error -> 500 Internal Server Error func (sm *SupervisorMux) humaHandleCityUnregister(ctx context.Context, input *SupervisorCityUnregisterInput) (*SupervisorCityUnregisterOutput, error) { if sm.initializer == nil { - return nil, huma.Error501NotImplemented("city unregister is not available in this supervisor (no initializer wired)") + return nil, apierr.NotImplemented.Msg("city unregister is not available in this supervisor (no initializer wired)") } name := strings.TrimSpace(input.CityName) if name == "" { - return nil, huma.Error400BadRequest("city_name is required") + return nil, apierr.InvalidRequest.Msg("city_name is required") } reqID, err := newRequestID() if err != nil { - return nil, huma.Error500InternalServerError(fmt.Sprintf("generating request ID: %v", err)) + return nil, apierr.Internal.Msg(fmt.Sprintf("generating request ID: %v", err)) } eventCursor, cursorErr := sm.currentSupervisorEventCursor() if cursorErr != nil { - return nil, huma.Error500InternalServerError(cursorErr.Error()) + return nil, apierr.Internal.Msg(cursorErr.Error()) } // Store the pending request_id BEFORE Unregister triggers a @@ -557,14 +558,14 @@ func (sm *SupervisorMux) humaHandleCityUnregister(ctx context.Context, input *Su var pathErr error cityPath, pathErr = sm.cityPathForPendingRequest(ctx, name) if pathErr != nil { - return nil, huma.Error500InternalServerError(fmt.Sprintf("resolving city path: %v", pathErr)) + return nil, apierr.Internal.Msg(fmt.Sprintf("resolving city path: %v", pathErr)) } if cityPath != "" { if err := store.StorePendingRequestID(cityPath, reqID); err != nil { if errors.Is(err, ErrPendingRequestExists) { - return nil, huma.Error409Conflict("conflict: city operation already in progress at " + cityPath) + return nil, apierr.OperationInProgress.Msg("conflict: city operation already in progress at " + cityPath) } - return nil, huma.Error500InternalServerError(fmt.Sprintf("storing pending request ID: %v", err)) + return nil, apierr.Internal.Msg(fmt.Sprintf("storing pending request ID: %v", err)) } } } @@ -577,14 +578,14 @@ func (sm *SupervisorMux) humaHandleCityUnregister(ctx context.Context, input *Su log.Printf("api: consume pending city unregister request ID for %s: %v", cityPath, err) } } - return nil, huma.Error404NotFound("not_found: " + unregErr.Error()) + return nil, apierr.CityNotFound.Msg("not_found: " + unregErr.Error()) case unregErr != nil: if store, ok := sm.resolver.(PendingRequestStore); ok && cityPath != "" { if _, _, err := store.ConsumePendingRequestID(cityPath); err != nil { log.Printf("api: consume pending city unregister request ID for %s: %v", cityPath, err) } } - return nil, huma.Error500InternalServerError(unregErr.Error()) + return nil, apierr.Internal.Msg(unregErr.Error()) } out := &SupervisorCityUnregisterOutput{Status: http.StatusAccepted} @@ -633,7 +634,7 @@ func (sm *SupervisorMux) humaHandleEventList(_ context.Context, input *Superviso mux := sm.buildMultiplexer() eventCursor, cursorErr := supervisorEventCursorFromMux(mux) if cursorErr != nil { - return nil, huma.Error500InternalServerError(cursorErr.Error()) + return nil, apierr.Internal.Msg(cursorErr.Error()) } filter := events.Filter{Type: input.Type, Actor: input.Actor} if d, ok, err := parseEventSince(input.Since); err != nil { @@ -650,7 +651,7 @@ func (sm *SupervisorMux) humaHandleEventList(_ context.Context, input *Superviso evts, err = mux.ListAll(filter) } if err != nil { - return nil, huma.Error500InternalServerError("internal: " + err.Error()) + return nil, apierr.Internal.Msg("internal: " + err.Error()) } wires := make([]WireTaggedEvent, 0, len(evts)) for _, e := range evts { @@ -741,14 +742,14 @@ func supervisorEventCursorFromMux(mux *events.Multiplexer) (string, error) { func (sm *SupervisorMux) precheckGlobalEventStream(ctx context.Context, _ *SupervisorEventStreamInput) error { mux := sm.buildMultiplexer() if mux.Len() == 0 { - return huma.Error503ServiceUnavailable("no_providers: no event providers available") + return apierr.ServiceUnavailable.Msg("no_providers: no event providers available") } probe, err := mux.Watch(ctx, nil) if err != nil { if errors.Is(err, events.ErrNoWatchers) { - return huma.Error503ServiceUnavailable("no_watchers: event providers are registered but none are watchable") + return apierr.ServiceUnavailable.Msg("no_watchers: event providers are registered but none are watchable") } - return huma.Error503ServiceUnavailable("watch_failed: " + err.Error()) + return apierr.ServiceUnavailable.Msg("watch_failed: " + err.Error()) } _ = probe.Close() return nil diff --git a/internal/api/huma_types_rigs.go b/internal/api/huma_types_rigs.go index 51cfa69d9f..05c47eb6b5 100644 --- a/internal/api/huma_types_rigs.go +++ b/internal/api/huma_types_rigs.go @@ -53,7 +53,7 @@ type RigDeleteInput struct { type RigActionInput struct { CityScope Name string `path:"name" doc:"Rig name."` - Action string `path:"action" doc:"Action to perform (suspend, resume, restart)."` + Action string `path:"action" enum:"suspend,resume,restart" doc:"Action to perform."` } // RigActionResponse is the response for rig actions (suspend/resume/restart). diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 1f15a4bb4b..ddd26ef171 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -2112,6 +2112,10 @@ "ErrorModel": { "additionalProperties": false, "properties": { + "code": { + "description": "Stable machine-readable error code (the final segment of the type URN).", + "type": "string" + }, "detail": { "description": "A human-readable explanation specific to this occurrence of the problem.", "examples": [ @@ -2157,16 +2161,88 @@ "description": "A URI reference to human-readable documentation for the error.", "examples": [ "https://example.com/errors/example", - "urn:gascity:error:sling-missing-bead", + "urn:gascity:error:agent-not-found", + "urn:gascity:error:ambiguous-reference", + "urn:gascity:error:bad-gateway", + "urn:gascity:error:bead-not-found", + "urn:gascity:error:city-not-found", + "urn:gascity:error:conflict-concurrent-delete", + "urn:gascity:error:conflict-concurrent-modify", + "urn:gascity:error:conflict-wrong-state", + "urn:gascity:error:convoy-not-found", + "urn:gascity:error:extmsg-group-not-found", + "urn:gascity:error:forbidden", + "urn:gascity:error:formula-not-found", + "urn:gascity:error:gateway-timeout", + "urn:gascity:error:idempotency-in-flight", + "urn:gascity:error:idempotency-mismatch", + "urn:gascity:error:internal", + "urn:gascity:error:invalid-request", + "urn:gascity:error:mail-not-found", + "urn:gascity:error:method-not-allowed", + "urn:gascity:error:not-implemented", + "urn:gascity:error:operation-in-progress", + "urn:gascity:error:order-not-found", + "urn:gascity:error:pack-not-found", + "urn:gascity:error:patch-not-found", + "urn:gascity:error:provider-not-found", + "urn:gascity:error:rig-not-found", + "urn:gascity:error:scope-not-found", + "urn:gascity:error:service-not-found", + "urn:gascity:error:service-unavailable", + "urn:gascity:error:session-conflict", + "urn:gascity:error:session-not-found", "urn:gascity:error:sling-cross-rig", - "urn:gascity:error:sling-cross-store-route" + "urn:gascity:error:sling-cross-store-route", + "urn:gascity:error:sling-missing-bead", + "urn:gascity:error:sling-source-workflow-conflict", + "urn:gascity:error:store-unavailable", + "urn:gascity:error:validation-failed", + "urn:gascity:error:webhook-rejected", + "urn:gascity:error:workflow-not-found" ], "format": "uri", "type": "string", "x-gascity-problem-types": [ - "urn:gascity:error:sling-missing-bead", + "urn:gascity:error:agent-not-found", + "urn:gascity:error:ambiguous-reference", + "urn:gascity:error:bad-gateway", + "urn:gascity:error:bead-not-found", + "urn:gascity:error:city-not-found", + "urn:gascity:error:conflict-concurrent-delete", + "urn:gascity:error:conflict-concurrent-modify", + "urn:gascity:error:conflict-wrong-state", + "urn:gascity:error:convoy-not-found", + "urn:gascity:error:extmsg-group-not-found", + "urn:gascity:error:forbidden", + "urn:gascity:error:formula-not-found", + "urn:gascity:error:gateway-timeout", + "urn:gascity:error:idempotency-in-flight", + "urn:gascity:error:idempotency-mismatch", + "urn:gascity:error:internal", + "urn:gascity:error:invalid-request", + "urn:gascity:error:mail-not-found", + "urn:gascity:error:method-not-allowed", + "urn:gascity:error:not-implemented", + "urn:gascity:error:operation-in-progress", + "urn:gascity:error:order-not-found", + "urn:gascity:error:pack-not-found", + "urn:gascity:error:patch-not-found", + "urn:gascity:error:provider-not-found", + "urn:gascity:error:rig-not-found", + "urn:gascity:error:scope-not-found", + "urn:gascity:error:service-not-found", + "urn:gascity:error:service-unavailable", + "urn:gascity:error:session-conflict", + "urn:gascity:error:session-not-found", "urn:gascity:error:sling-cross-rig", - "urn:gascity:error:sling-cross-store-route" + "urn:gascity:error:sling-cross-store-route", + "urn:gascity:error:sling-missing-bead", + "urn:gascity:error:sling-source-workflow-conflict", + "urn:gascity:error:store-unavailable", + "urn:gascity:error:validation-failed", + "urn:gascity:error:webhook-rejected", + "urn:gascity:error:workflow-not-found" ] } }, @@ -17774,7 +17850,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -17782,7 +17858,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -17845,7 +17951,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -17853,7 +17959,97 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -17918,7 +18114,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -17926,7 +18122,112 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -17993,7 +18294,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -18001,7 +18302,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -18074,7 +18405,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -18082,31 +18413,136 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Patch v0 city by city name agent by base" - } - }, - "/v0/city/{cityName}/agent/{base}/output": { - "get": { - "operationId": "get-v0-city-by-city-name-agent-by-base-output", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Patch v0 city by city name agent by base" + } + }, + "/v0/city/{cityName}/agent/{base}/output": { + "get": { + "operationId": "get-v0-city-by-city-name-agent-by-base-output", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" } }, { @@ -18156,7 +18592,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -18164,7 +18600,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -18371,7 +18837,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -18379,7 +18845,97 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -18454,7 +19010,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -18462,38 +19018,143 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Delete v0 city by city name agent by dir by base" - }, - "get": { - "operationId": "get-v0-city-by-city-name-agent-by-dir-by-base", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "Agent directory (rig name).", - "in": "path", - "name": "dir", - "required": true, - "schema": { - "description": "Agent directory (rig name).", + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name agent by dir by base" + }, + "get": { + "operationId": "get-v0-city-by-city-name-agent-by-dir-by-base", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Agent directory (rig name).", + "in": "path", + "name": "dir", + "required": true, + "schema": { + "description": "Agent directory (rig name).", "type": "string" } }, @@ -18539,7 +19200,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -18547,7 +19208,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -18630,7 +19321,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -18638,7 +19329,112 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -18722,7 +19518,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -18730,7 +19526,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -18957,7 +19783,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -18965,14 +19791,104 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, "summary": "Post v0 city by city name agent by dir by base by action" } }, @@ -19088,7 +20004,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -19096,7 +20012,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -19160,7 +20106,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -19168,7 +20114,142 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "504": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Gateway Timeout", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -19233,7 +20314,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -19241,40 +20322,115 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Delete v0 city by city name bead by ID" - }, - "get": { - "operationId": "get-v0-city-by-city-name-bead-by-id", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Bead ID.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Bead ID.", - "type": "string" - } + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name bead by ID" + }, + "get": { + "operationId": "get-v0-city-by-city-name-bead-by-id", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Bead ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Bead ID.", + "type": "string" + } } ], "responses": { @@ -19308,7 +20464,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -19316,7 +20472,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -19389,7 +20590,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -19397,7 +20598,97 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -19490,7 +20781,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -19498,45 +20789,135 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name bead by ID assign" - } - }, - "/v0/city/{cityName}/bead/{id}/close": { - "post": { - "operationId": "post-v0-city-by-city-name-bead-by-id-close", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name bead by ID assign" + } + }, + "/v0/city/{cityName}/bead/{id}/close": { + "post": { + "operationId": "post-v0-city-by-city-name-bead-by-id-close", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { "description": "Bead ID.", "in": "path", "name": "id", @@ -19563,7 +20944,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -19571,7 +20952,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -19640,7 +21096,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -19648,7 +21104,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -19713,7 +21199,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -19721,82 +21207,29 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name bead by ID reopen" - } - }, - "/v0/city/{cityName}/bead/{id}/update": { - "post": { - "operationId": "post-v0-city-by-city-name-bead-by-id-update", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Bead ID.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Bead ID.", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BeadUpdateBody" - } - } }, - "required": true - }, - "responses": { - "200": { + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Forbidden", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -19804,13 +21237,231 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name bead by ID reopen" + } + }, + "/v0/city/{cityName}/bead/{id}/update": { + "post": { + "operationId": "post-v0-city-by-city-name-bead-by-id-update", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Bead ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Bead ID.", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BeadUpdateBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } }, "summary": "Post v0 city by city name bead by ID update" } @@ -19965,7 +21616,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -19973,7 +21624,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20060,7 +21756,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -20068,26 +21764,116 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Create a bead" - } - }, - "/v0/city/{cityName}/beads/graph/{rootID}": { - "get": { - "operationId": "get-v0-city-by-city-name-beads-graph-by-root-id", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Create a bead" + } + }, + "/v0/city/{cityName}/beads/graph/{rootID}": { + "get": { + "operationId": "get-v0-city-by-city-name-beads-graph-by-root-id", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, "schema": { "description": "City name.", "minLength": 1, @@ -20137,7 +21923,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -20145,7 +21931,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20224,7 +22040,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -20232,7 +22048,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20291,7 +22152,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -20299,7 +22160,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20358,7 +22249,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -20366,7 +22257,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20425,7 +22346,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -20433,7 +22354,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20477,7 +22428,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -20485,21 +22436,51 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name config validate" - } - }, - "/v0/city/{cityName}/convoy/{id}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-convoy-by-id", - "parameters": [ + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name config validate" + } + }, + "/v0/city/{cityName}/convoy/{id}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-convoy-by-id", + "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", "in": "header", @@ -20550,7 +22531,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -20558,7 +22539,82 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20625,7 +22681,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -20633,7 +22689,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20708,7 +22809,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -20716,7 +22817,82 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20785,7 +22961,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -20793,48 +22969,108 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name convoy by ID check" - } - }, - "/v0/city/{cityName}/convoy/{id}/close": { - "post": { - "operationId": "post-v0-city-by-city-name-convoy-by-id-close", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "Convoy ID.", - "in": "path", - "name": "id", + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name convoy by ID check" + } + }, + "/v0/city/{cityName}/convoy/{id}/close": { + "post": { + "operationId": "post-v0-city-by-city-name-convoy-by-id-close", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Convoy ID.", + "in": "path", + "name": "id", "required": true, "schema": { "description": "Convoy ID.", @@ -20858,7 +23094,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -20866,7 +23102,82 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -20941,7 +23252,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -20949,7 +23260,82 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -21050,7 +23436,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -21058,7 +23444,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -21136,7 +23567,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -21144,17 +23575,92 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Create a convoy" - } - }, + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Create a convoy" + } + }, "/v0/city/{cityName}/events": { "get": { "operationId": "get-v0-city-by-city-name-events", @@ -21275,7 +23781,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -21283,7 +23789,52 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -21346,7 +23897,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -21354,7 +23905,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -21419,7 +24045,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -21427,7 +24053,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Method Not Allowed", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -21622,7 +24323,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -21630,21 +24331,96 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Delete v0 city by city name extmsg adapters" - }, - "get": { - "operationId": "get-v0-city-by-city-name-extmsg-adapters", - "parameters": [ - { - "description": "City name.", + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name extmsg adapters" + }, + "get": { + "operationId": "get-v0-city-by-city-name-extmsg-adapters", + "parameters": [ + { + "description": "City name.", "in": "path", "name": "cityName", "required": true, @@ -21687,7 +24463,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -21695,7 +24471,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -21758,7 +24579,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -21766,7 +24587,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -21831,7 +24727,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -21839,76 +24735,44 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name extmsg bind" - } - }, - "/v0/city/{cityName}/extmsg/bindings": { - "get": { - "operationId": "get-v0-city-by-city-name-extmsg-bindings", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Session ID to list bindings for.", - "explode": false, - "in": "query", - "name": "session_id", - "schema": { - "description": "Session ID to list bindings for.", - "type": "string" - } - } - ], - "responses": { - "200": { + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ListBodySessionBindingRecord" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unauthorized", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Forbidden", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -21916,20 +24780,217 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name extmsg bindings" - } - }, - "/v0/city/{cityName}/extmsg/groups": { - "get": { - "operationId": "get-v0-city-by-city-name-extmsg-groups", + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name extmsg bind" + } + }, + "/v0/city/{cityName}/extmsg/bindings": { + "get": { + "operationId": "get-v0-city-by-city-name-extmsg-bindings", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Session ID to list bindings for.", + "explode": false, + "in": "query", + "name": "session_id", + "schema": { + "description": "Session ID to list bindings for.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListBodySessionBindingRecord" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name extmsg bindings" + } + }, + "/v0/city/{cityName}/extmsg/groups": { + "get": { + "operationId": "get-v0-city-by-city-name-extmsg-groups", "parameters": [ { "description": "City name.", @@ -22010,7 +25071,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -22018,7 +25079,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22081,7 +25187,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -22089,72 +25195,59 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Ensure an external messaging group exists" - } - }, - "/v0/city/{cityName}/extmsg/inbound": { - "post": { - "operationId": "post-v0-city-by-city-name-extmsg-inbound", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExtMsgInboundInputBody" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "200": { + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/InboundResult" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -22162,7 +25255,22 @@ } } }, - "description": "Error", + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22170,12 +25278,12 @@ } } }, - "summary": "Post v0 city by city name extmsg inbound" + "summary": "Ensure an external messaging group exists" } }, - "/v0/city/{cityName}/extmsg/outbound": { + "/v0/city/{cityName}/extmsg/inbound": { "post": { - "operationId": "post-v0-city-by-city-name-extmsg-outbound", + "operationId": "post-v0-city-by-city-name-extmsg-inbound", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -22205,7 +25313,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExtMsgOutboundInputBody" + "$ref": "#/components/schemas/ExtMsgInboundInputBody" } } }, @@ -22216,7 +25324,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OutboundResult" + "$ref": "#/components/schemas/InboundResult" } } }, @@ -22227,7 +25335,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -22235,7 +25343,97 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22243,12 +25441,12 @@ } } }, - "summary": "Post v0 city by city name extmsg outbound" + "summary": "Post v0 city by city name extmsg inbound" } }, - "/v0/city/{cityName}/extmsg/participants": { - "delete": { - "operationId": "delete-v0-city-by-city-name-extmsg-participants", + "/v0/city/{cityName}/extmsg/outbound": { + "post": { + "operationId": "post-v0-city-by-city-name-extmsg-outbound", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -22278,7 +25476,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExtMsgParticipantRemoveInputBody" + "$ref": "#/components/schemas/ExtMsgOutboundInputBody" } } }, @@ -22289,7 +25487,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/OutboundResult" } } }, @@ -22300,7 +25498,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -22308,7 +25506,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22316,10 +25589,12 @@ } } }, - "summary": "Delete v0 city by city name extmsg participants" - }, - "post": { - "operationId": "post-v0-city-by-city-name-extmsg-participants", + "summary": "Post v0 city by city name extmsg outbound" + } + }, + "/v0/city/{cityName}/extmsg/participants": { + "delete": { + "operationId": "delete-v0-city-by-city-name-extmsg-participants", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -22349,7 +25624,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExtMsgParticipantUpsertInputBody" + "$ref": "#/components/schemas/ExtMsgParticipantRemoveInputBody" } } }, @@ -22360,7 +25635,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConversationGroupParticipant" + "$ref": "#/components/schemas/OKResponseBody" } } }, @@ -22371,7 +25646,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -22379,27 +25654,248 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name extmsg participants" - } - }, - "/v0/city/{cityName}/extmsg/transcript": { - "get": { - "operationId": "get-v0-city-by-city-name-extmsg-transcript", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name extmsg participants" + }, + "post": { + "operationId": "post-v0-city-by-city-name-extmsg-participants", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExtMsgParticipantUpsertInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationGroupParticipant" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name extmsg participants" + } + }, + "/v0/city/{cityName}/extmsg/transcript": { + "get": { + "operationId": "get-v0-city-by-city-name-extmsg-transcript", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { "description": "City name.", "minLength": 1, "pattern": "\\S", @@ -22534,7 +26030,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -22542,7 +26038,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22607,7 +26148,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -22615,7 +26156,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22680,7 +26296,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -22688,7 +26304,97 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22773,7 +26479,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -22781,7 +26487,67 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22845,7 +26611,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -22853,7 +26619,67 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -22929,7 +26755,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -22937,7 +26763,67 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -23004,7 +26890,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -23012,7 +26898,97 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -23095,7 +27071,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -23103,7 +27079,67 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -23180,7 +27216,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -23188,82 +27224,44 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Put v0 city by city name formulas by name" - } - }, - "/v0/city/{cityName}/formulas/{name}/preview": { - "post": { - "operationId": "post-v0-city-by-city-name-formulas-by-name-preview", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Formula name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Formula name.", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FormulaPreviewBody" + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "200": { + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/FormulaDetailResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Forbidden", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -23271,95 +27269,29 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name formulas by name preview" - } - }, - "/v0/city/{cityName}/formulas/{name}/runs": { - "get": { - "operationId": "get-v0-city-by-city-name-formulas-by-name-runs", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Formula name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Formula name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Scope kind (city or rig).", - "explode": false, - "in": "query", - "name": "scope_kind", - "schema": { - "description": "Scope kind (city or rig).", - "type": "string" - } - }, - { - "description": "Scope reference.", - "explode": false, - "in": "query", - "name": "scope_ref", - "schema": { - "description": "Scope reference.", - "type": "string" - } - }, - { - "description": "Maximum number of recent runs to return. 0 = default.", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "description": "Maximum number of recent runs to return. 0 = default.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - } - ], - "responses": { - "200": { + "413": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/FormulaRunsResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Request Entity Too Large", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "422": { "content": { "application/problem+json": { "schema": { @@ -23367,63 +27299,29 @@ } } }, - "description": "Error", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name formulas by name runs" - } - }, - "/v0/city/{cityName}/formulas/{name}/source": { - "get": { - "operationId": "get-v0-city-by-city-name-formulas-by-name-source", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Formula name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Formula name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/FormulaSourceOutputBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "501": { "content": { "application/problem+json": { "schema": { @@ -23431,7 +27329,7 @@ } } }, - "description": "Error", + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -23439,12 +27337,12 @@ } } }, - "summary": "Get v0 city by city name formulas by name source" + "summary": "Put v0 city by city name formulas by name" } }, - "/v0/city/{cityName}/formulas/{name}/validate": { + "/v0/city/{cityName}/formulas/{name}/preview": { "post": { - "operationId": "post-v0-city-by-city-name-formulas-by-name-validate", + "operationId": "post-v0-city-by-city-name-formulas-by-name-preview", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -23476,19 +27374,15 @@ "required": true, "schema": { "description": "Formula name.", - "minLength": 1, - "pattern": "\\S", "type": "string" } } ], "requestBody": { "content": { - "application/octet-stream": { + "application/json": { "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" + "$ref": "#/components/schemas/FormulaPreviewBody" } } }, @@ -23499,7 +27393,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FormulaValidateOutputBody" + "$ref": "#/components/schemas/FormulaDetailResponse" } } }, @@ -23510,7 +27404,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -23518,51 +27412,44 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name formulas by name validate" - } - }, - "/v0/city/{cityName}/health": { - "get": { - "operationId": "get-v0-city-by-city-name-health", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } - } - ], - "responses": { - "200": { + }, + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/HealthOutputBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Forbidden", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -23570,7 +27457,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -23578,12 +27510,12 @@ } } }, - "summary": "Get v0 city by city name health" + "summary": "Post v0 city by city name formulas by name preview" } }, - "/v0/city/{cityName}/mail": { + "/v0/city/{cityName}/formulas/{name}/runs": { "get": { - "operationId": "get-v0-city-by-city-name-mail", + "operationId": "get-v0-city-by-city-name-formulas-by-name-runs", "parameters": [ { "description": "City name.", @@ -23598,76 +27530,48 @@ } }, { - "description": "Event sequence number; when provided, blocks until a newer event arrives.", - "explode": false, - "in": "query", - "name": "index", + "description": "Formula name.", + "in": "path", + "name": "name", + "required": true, "schema": { - "description": "Event sequence number; when provided, blocks until a newer event arrives.", + "description": "Formula name.", + "minLength": 1, + "pattern": "\\S", "type": "string" } }, { - "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", + "description": "Scope kind (city or rig).", "explode": false, "in": "query", - "name": "wait", + "name": "scope_kind", "schema": { - "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", + "description": "Scope kind (city or rig).", "type": "string" } }, { - "description": "Pagination cursor from a previous response's next_cursor field.", + "description": "Scope reference.", "explode": false, "in": "query", - "name": "cursor", + "name": "scope_ref", "schema": { - "description": "Pagination cursor from a previous response's next_cursor field.", + "description": "Scope reference.", "type": "string" } }, { - "description": "Maximum number of results to return. 0 = server default.", + "description": "Maximum number of recent runs to return. 0 = default.", "explode": false, "in": "query", "name": "limit", "schema": { - "description": "Maximum number of results to return. 0 = server default.", + "description": "Maximum number of recent runs to return. 0 = default.", "format": "int64", "minimum": 0, "type": "integer" } - }, - { - "description": "Filter by agent name.", - "explode": false, - "in": "query", - "name": "agent", - "schema": { - "description": "Filter by agent name.", - "type": "string" - } - }, - { - "description": "Filter by status (unread, all).", - "explode": false, - "in": "query", - "name": "status", - "schema": { - "description": "Filter by status (unread, all).", - "type": "string" - } - }, - { - "description": "Filter by rig name.", - "explode": false, - "in": "query", - "name": "rig", - "schema": { - "description": "Filter by rig name.", - "type": "string" - } } ], "responses": { @@ -23675,33 +27579,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MailListBody" + "$ref": "#/components/schemas/FormulaRunsResponse" } } }, "description": "OK", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -23709,94 +27598,59 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name mail" - }, - "post": { - "operationId": "send-mail", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Idempotency key for safe retries.", - "in": "header", - "name": "Idempotency-Key", - "schema": { - "description": "Idempotency key for safe retries.", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MailSendInputBody" + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "201": { + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Message" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "Created", + "description": "Unprocessable Entity", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Internal Server Error", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -23804,7 +27658,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -23812,12 +27666,12 @@ } } }, - "summary": "Send a mail message" + "summary": "Get v0 city by city name formulas by name runs" } }, - "/v0/city/{cityName}/mail/count": { + "/v0/city/{cityName}/formulas/{name}/source": { "get": { - "operationId": "get-v0-city-by-city-name-mail-count", + "operationId": "get-v0-city-by-city-name-formulas-by-name-source", "parameters": [ { "description": "City name.", @@ -23832,22 +27686,14 @@ } }, { - "description": "Filter by agent name.", - "explode": false, - "in": "query", - "name": "agent", - "schema": { - "description": "Filter by agent name.", - "type": "string" - } - }, - { - "description": "Filter by rig name.", - "explode": false, - "in": "query", - "name": "rig", + "description": "Formula name.", + "in": "path", + "name": "name", + "required": true, "schema": { - "description": "Filter by rig name.", + "description": "Formula name.", + "minLength": 1, + "pattern": "\\S", "type": "string" } } @@ -23857,25 +27703,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MailCountOutputBody" + "$ref": "#/components/schemas/FormulaSourceOutputBody" } } }, "description": "OK", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -23883,86 +27722,59 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name mail count" - } - }, - "/v0/city/{cityName}/mail/thread/{id}": { - "get": { - "operationId": "get-v0-city-by-city-name-mail-thread-by-id", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Thread ID, or any message ID in the thread.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Thread ID, or any message ID in the thread.", - "type": "string" - } }, - { - "description": "Filter by rig.", - "explode": false, - "in": "query", - "name": "rig", - "schema": { - "description": "Filter by rig.", - "type": "string" - } - } - ], - "responses": { - "200": { + "404": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/MailListBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Not Found", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Internal Server Error", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "501": { "content": { "application/problem+json": { "schema": { @@ -23970,7 +27782,7 @@ } } }, - "description": "Error", + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -23978,12 +27790,12 @@ } } }, - "summary": "Get v0 city by city name mail thread by ID" + "summary": "Get v0 city by city name formulas by name source" } }, - "/v0/city/{cityName}/mail/{id}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-mail-by-id", + "/v0/city/{cityName}/formulas/{name}/validate": { + "post": { + "operationId": "post-v0-city-by-city-name-formulas-by-name-validate", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -24009,32 +27821,36 @@ } }, { - "description": "Message ID.", + "description": "Formula name.", "in": "path", - "name": "id", + "name": "name", "required": true, "schema": { - "description": "Message ID.", - "type": "string" - } - }, - { - "description": "Rig hint.", - "explode": false, - "in": "query", - "name": "rig", - "schema": { - "description": "Rig hint.", + "description": "Formula name.", + "minLength": 1, + "pattern": "\\S", "type": "string" } } ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/FormulaValidateOutputBody" } } }, @@ -24045,7 +27861,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -24053,84 +27869,74 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Delete v0 city by city name mail by ID" - }, - "get": { - "operationId": "get-v0-city-by-city-name-mail-by-id", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Message ID.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Message ID.", - "type": "string" + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "Rig hint for O(1) lookup.", - "explode": false, - "in": "query", - "name": "rig", - "schema": { - "description": "Rig hint for O(1) lookup.", - "type": "string" - } - } - ], - "responses": { - "200": { + "404": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Message" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Not Found", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "413": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Request Entity Too Large", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Unprocessable Entity", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -24138,7 +27944,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24146,24 +27952,13 @@ } } }, - "summary": "Get v0 city by city name mail by ID" + "summary": "Post v0 city by city name formulas by name validate" } }, - "/v0/city/{cityName}/mail/{id}/archive": { - "post": { - "operationId": "post-v0-city-by-city-name-mail-by-id-archive", + "/v0/city/{cityName}/health": { + "get": { + "operationId": "get-v0-city-by-city-name-health", "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, { "description": "City name.", "in": "path", @@ -24175,26 +27970,6 @@ "pattern": "\\S", "type": "string" } - }, - { - "description": "Message ID.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Message ID.", - "type": "string" - } - }, - { - "description": "Rig hint.", - "explode": false, - "in": "query", - "name": "rig", - "schema": { - "description": "Rig hint.", - "type": "string" - } } ], "responses": { @@ -24202,7 +27977,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/HealthOutputBody" } } }, @@ -24213,7 +27988,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -24221,82 +27996,29 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name mail by ID archive" - } - }, - "/v0/city/{cityName}/mail/{id}/mark-unread": { - "post": { - "operationId": "post-v0-city-by-city-name-mail-by-id-mark-unread", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Message ID.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Message ID.", - "type": "string" - } - }, - { - "description": "Rig hint.", - "explode": false, - "in": "query", - "name": "rig", - "schema": { - "description": "Rig hint.", - "type": "string" - } - } - ], - "responses": { - "200": { + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -24304,7 +28026,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24312,24 +28034,13 @@ } } }, - "summary": "Post v0 city by city name mail by ID mark unread" + "summary": "Get v0 city by city name health" } }, - "/v0/city/{cityName}/mail/{id}/read": { - "post": { - "operationId": "post-v0-city-by-city-name-mail-by-id-read", + "/v0/city/{cityName}/mail": { + "get": { + "operationId": "get-v0-city-by-city-name-mail", "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, { "description": "City name.", "in": "path", @@ -24343,43 +28054,5342 @@ } }, { - "description": "Message ID.", - "in": "path", - "name": "id", - "required": true, + "description": "Event sequence number; when provided, blocks until a newer event arrives.", + "explode": false, + "in": "query", + "name": "index", "schema": { - "description": "Message ID.", + "description": "Event sequence number; when provided, blocks until a newer event arrives.", "type": "string" } }, { - "description": "Rig hint.", + "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", + "explode": false, + "in": "query", + "name": "wait", + "schema": { + "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", + "type": "string" + } + }, + { + "description": "Pagination cursor from a previous response's next_cursor field.", + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "description": "Pagination cursor from a previous response's next_cursor field.", + "type": "string" + } + }, + { + "description": "Maximum number of results to return. 0 = server default.", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "description": "Maximum number of results to return. 0 = server default.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Filter by agent name.", + "explode": false, + "in": "query", + "name": "agent", + "schema": { + "description": "Filter by agent name.", + "type": "string" + } + }, + { + "description": "Filter by status (unread, all).", + "explode": false, + "in": "query", + "name": "status", + "schema": { + "description": "Filter by status (unread, all).", + "type": "string" + } + }, + { + "description": "Filter by rig name.", "explode": false, "in": "query", "name": "rig", "schema": { - "description": "Rig hint.", + "description": "Filter by rig name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailListBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name mail" + }, + "post": { + "operationId": "send-mail", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Idempotency key for safe retries.", + "in": "header", + "name": "Idempotency-Key", + "schema": { + "description": "Idempotency key for safe retries.", "type": "string" } - } - ], - "responses": { - "200": { + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailSendInputBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + }, + "description": "Created", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Send a mail message" + } + }, + "/v0/city/{cityName}/mail/count": { + "get": { + "operationId": "get-v0-city-by-city-name-mail-count", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Filter by agent name.", + "explode": false, + "in": "query", + "name": "agent", + "schema": { + "description": "Filter by agent name.", + "type": "string" + } + }, + { + "description": "Filter by rig name.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Filter by rig name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailCountOutputBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name mail count" + } + }, + "/v0/city/{cityName}/mail/thread/{id}": { + "get": { + "operationId": "get-v0-city-by-city-name-mail-thread-by-id", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Thread ID, or any message ID in the thread.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Thread ID, or any message ID in the thread.", + "type": "string" + } + }, + { + "description": "Filter by rig.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Filter by rig.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailListBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name mail thread by ID" + } + }, + "/v0/city/{cityName}/mail/{id}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-mail-by-id", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Message ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Message ID.", + "type": "string" + } + }, + { + "description": "Rig hint.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Rig hint.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name mail by ID" + }, + "get": { + "operationId": "get-v0-city-by-city-name-mail-by-id", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Message ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Message ID.", + "type": "string" + } + }, + { + "description": "Rig hint for O(1) lookup.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Rig hint for O(1) lookup.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name mail by ID" + } + }, + "/v0/city/{cityName}/mail/{id}/archive": { + "post": { + "operationId": "post-v0-city-by-city-name-mail-by-id-archive", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Message ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Message ID.", + "type": "string" + } + }, + { + "description": "Rig hint.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Rig hint.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name mail by ID archive" + } + }, + "/v0/city/{cityName}/mail/{id}/mark-unread": { + "post": { + "operationId": "post-v0-city-by-city-name-mail-by-id-mark-unread", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Message ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Message ID.", + "type": "string" + } + }, + { + "description": "Rig hint.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Rig hint.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name mail by ID mark unread" + } + }, + "/v0/city/{cityName}/mail/{id}/read": { + "post": { + "operationId": "post-v0-city-by-city-name-mail-by-id-read", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Message ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Message ID.", + "type": "string" + } + }, + { + "description": "Rig hint.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Rig hint.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name mail by ID read" + } + }, + "/v0/city/{cityName}/mail/{id}/reply": { + "post": { + "operationId": "reply-mail", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Message ID.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Message ID.", + "type": "string" + } + }, + { + "description": "Rig hint.", + "explode": false, + "in": "query", + "name": "rig", + "schema": { + "description": "Rig hint.", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MailReplyInputBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Message" + } + } + }, + "description": "Created", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Reply to a mail message" + } + }, + "/v0/city/{cityName}/maintenance/dolt-gc": { + "post": { + "description": "Trigger a one-off maintenance cycle (dolt backup + CALL DOLT_GC + smoke test). Default async (202); ?wait=true blocks until completion (200). Returns 409 when a run is already in flight.", + "operationId": "trigger-maintenance-dolt-gc", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "When true, the handler blocks until the run completes and returns 200 with the full Run. When false (default), the handler returns 202 Accepted immediately.", + "explode": false, + "in": "query", + "name": "wait", + "schema": { + "description": "When true, the handler blocks until the run completes and returns 200 with the full Run. When false (default), the handler returns 202 Accepted immediately.", + "type": "boolean" + } + } + ], + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceTriggerBody" + } + } + }, + "description": "Accepted", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Trigger a Dolt store maintenance run" + } + }, + "/v0/city/{cityName}/maintenance/status": { + "get": { + "operationId": "get-v0-city-by-city-name-maintenance-status", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceStatusBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "format": "double", + "type": "number" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name maintenance status" + } + }, + "/v0/city/{cityName}/order/history/{bead_id}": { + "get": { + "operationId": "get-v0-city-by-city-name-order-history-by-bead-id", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Bead ID for the order run.", + "in": "path", + "name": "bead_id", + "required": true, + "schema": { + "description": "Bead ID for the order run.", + "type": "string" + } + }, + { + "description": "Store reference for disambiguating store-local bead IDs.", + "explode": false, + "in": "query", + "name": "store_ref", + "schema": { + "description": "Store reference for disambiguating store-local bead IDs.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderHistoryDetailResponse" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name order history by bead ID" + } + }, + "/v0/city/{cityName}/order/{name}": { + "get": { + "operationId": "get-v0-city-by-city-name-order-by-name", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Order name or scoped name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Order name or scoped name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderResponse" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name order by name" + } + }, + "/v0/city/{cityName}/order/{name}/disable": { + "post": { + "operationId": "post-v0-city-by-city-name-order-by-name-disable", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Order name or scoped name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Order name or scoped name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name order by name disable" + } + }, + "/v0/city/{cityName}/order/{name}/enable": { + "post": { + "operationId": "post-v0-city-by-city-name-order-by-name-enable", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Order name or scoped name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Order name or scoped name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name order by name enable" + } + }, + "/v0/city/{cityName}/order/{name}/run": { + "post": { + "operationId": "post-v0-city-by-city-name-order-by-name-run", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Order name or scoped name of a trigger=\"webhook\" order.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Order name or scoped name of a trigger=\"webhook\" order.", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderRunInputBody" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderRunOutputBody" + } + } + }, + "description": "Accepted", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Post v0 city by city name order by name run" + } + }, + "/v0/city/{cityName}/orders": { + "get": { + "operationId": "get-v0-city-by-city-name-orders", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderListBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name orders" + } + }, + "/v0/city/{cityName}/orders/check": { + "get": { + "operationId": "get-v0-city-by-city-name-orders-check", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Bypass cached order-check responses and cached order history.", + "explode": false, + "in": "query", + "name": "fresh", + "schema": { + "description": "Bypass cached order-check responses and cached order history.", + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderCheckListBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name orders check" + } + }, + "/v0/city/{cityName}/orders/feed": { + "get": { + "operationId": "get-v0-city-by-city-name-orders-feed", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Scope kind (city or rig).", + "explode": false, + "in": "query", + "name": "scope_kind", + "schema": { + "description": "Scope kind (city or rig).", + "type": "string" + } + }, + { + "description": "Scope reference.", + "explode": false, + "in": "query", + "name": "scope_ref", + "schema": { + "description": "Scope reference.", + "type": "string" + } + }, + { + "description": "Maximum number of feed items to return.", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "description": "Maximum number of feed items to return.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrdersFeedBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name orders feed" + } + }, + "/v0/city/{cityName}/orders/history": { + "get": { + "operationId": "get-v0-city-by-city-name-orders-history", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Scoped order name.", + "explode": false, + "in": "query", + "name": "scoped_name", + "required": true, + "schema": { + "description": "Scoped order name.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "Maximum number of history entries. 0 = default.", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "description": "Maximum number of history entries. 0 = default.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Return entries before this RFC3339 timestamp.", + "explode": false, + "in": "query", + "name": "before", + "schema": { + "description": "Return entries before this RFC3339 timestamp.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderHistoryListBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name orders history" + } + }, + "/v0/city/{cityName}/packs": { + "get": { + "operationId": "get-v0-city-by-city-name-packs", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackListBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name packs" + }, + "post": { + "description": "Imports a pack into the city by source (a remote git URL or registry ref), resolving + installing it so its templates compose into the city.", + "operationId": "add-pack", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackAddInputBody" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackAddedOutputBody" + } + } + }, + "description": "Created", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "502": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Gateway", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Add a pack" + } + }, + "/v0/city/{cityName}/packs/{name}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-packs-by-name", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "The import binding name to remove (the [imports.\u003cname\u003e] key).", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "The import binding name to remove (the [imports.\u003cname\u003e] key).", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PackRemovedOutputBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name packs by name" + } + }, + "/v0/city/{cityName}/patches/agent/{base}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-patches-agent-by-base", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Agent patch name (unqualified).", + "in": "path", + "name": "base", + "required": true, + "schema": { + "description": "Agent patch name (unqualified).", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchDeletedResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name patches agent by base" + }, + "get": { + "operationId": "get-v0-city-by-city-name-patches-agent-by-base", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Agent patch name (unqualified).", + "in": "path", + "name": "base", + "required": true, + "schema": { + "description": "Agent patch name (unqualified).", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentPatch" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name patches agent by base" + } + }, + "/v0/city/{cityName}/patches/agent/{dir}/{base}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-patches-agent-by-dir-by-base", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Agent directory (rig name).", + "in": "path", + "name": "dir", + "required": true, + "schema": { + "description": "Agent directory (rig name).", + "type": "string" + } + }, + { + "description": "Agent base name.", + "in": "path", + "name": "base", + "required": true, + "schema": { + "description": "Agent base name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchDeletedResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name patches agent by dir by base" + }, + "get": { + "operationId": "get-v0-city-by-city-name-patches-agent-by-dir-by-base", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Agent directory (rig name).", + "in": "path", + "name": "dir", + "required": true, + "schema": { + "description": "Agent directory (rig name).", + "type": "string" + } + }, + { + "description": "Agent base name.", + "in": "path", + "name": "base", + "required": true, + "schema": { + "description": "Agent base name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentPatch" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name patches agent by dir by base" + } + }, + "/v0/city/{cityName}/patches/agents": { + "get": { + "operationId": "get-v0-city-by-city-name-patches-agents", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListBodyAgentPatch" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name patches agents" + }, + "put": { + "operationId": "put-v0-city-by-city-name-patches-agents", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentPatchSetInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchOKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Put v0 city by city name patches agents" + } + }, + "/v0/city/{cityName}/patches/provider/{name}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-patches-provider-by-name", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Provider patch name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Provider patch name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchDeletedResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name patches provider by name" + }, + "get": { + "operationId": "get-v0-city-by-city-name-patches-provider-by-name", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Provider patch name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Provider patch name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderPatch" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name patches provider by name" + } + }, + "/v0/city/{cityName}/patches/providers": { + "get": { + "operationId": "get-v0-city-by-city-name-patches-providers", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListBodyProviderPatch" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name patches providers" + }, + "put": { + "operationId": "put-v0-city-by-city-name-patches-providers", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderPatchSetInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchOKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Put v0 city by city name patches providers" + } + }, + "/v0/city/{cityName}/patches/rig/{name}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-patches-rig-by-name", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Rig patch name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Rig patch name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchDeletedResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Delete v0 city by city name patches rig by name" + }, + "get": { + "operationId": "get-v0-city-by-city-name-patches-rig-by-name", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Rig patch name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Rig patch name.", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RigPatch" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name patches rig by name" + } + }, + "/v0/city/{cityName}/patches/rigs": { + "get": { + "operationId": "get-v0-city-by-city-name-patches-rigs", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListBodyRigPatch" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Get v0 city by city name patches rigs" + }, + "put": { + "operationId": "put-v0-city-by-city-name-patches-rigs", + "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RigPatchSetInputBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PatchOKResponseBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + } + }, + "summary": "Put v0 city by city name patches rigs" + } + }, + "/v0/city/{cityName}/pending": { + "get": { + "operationId": "get-v0-city-by-city-name-pending", + "parameters": [ + { + "description": "City name.", + "in": "path", + "name": "cityName", + "required": true, + "schema": { + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListBodyCityPendingEntry" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -24387,7 +33397,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24395,24 +33405,13 @@ } } }, - "summary": "Post v0 city by city name mail by ID read" + "summary": "Get v0 city by city name pending" } }, - "/v0/city/{cityName}/mail/{id}/reply": { - "post": { - "operationId": "reply-mail", + "/v0/city/{cityName}/provider-readiness": { + "get": { + "operationId": "get-v0-city-by-city-name-provider-readiness", "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, { "description": "City name.", "in": "path", @@ -24426,68 +33425,88 @@ } }, { - "description": "Message ID.", - "in": "path", - "name": "id", - "required": true, + "description": "Comma-separated provider names to check (default: claude,codex,gemini).", + "explode": false, + "in": "query", + "name": "providers", "schema": { - "description": "Message ID.", + "description": "Comma-separated provider names to check (default: claude,codex,gemini).", "type": "string" } }, { - "description": "Rig hint.", + "description": "Force fresh probe, bypassing cache.", "explode": false, "in": "query", - "name": "rig", + "name": "fresh", "schema": { - "description": "Rig hint.", - "type": "string" + "description": "Force fresh probe, bypassing cache.", + "type": "boolean" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MailReplyInputBody" + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderReadinessResponse" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "201": { + "400": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Message" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "Created", + "description": "Bad Request", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Unprocessable Entity", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -24495,7 +33514,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24503,13 +33522,12 @@ } } }, - "summary": "Reply to a mail message" + "summary": "Get v0 city by city name provider readiness" } }, - "/v0/city/{cityName}/maintenance/dolt-gc": { - "post": { - "description": "Trigger a one-off maintenance cycle (dolt backup + CALL DOLT_GC + smoke test). Default async (202); ?wait=true blocks until completion (200). Returns 409 when a run is already in flight.", - "operationId": "trigger-maintenance-dolt-gc", + "/v0/city/{cityName}/provider/{name}": { + "delete": { + "operationId": "delete-v0-city-by-city-name-provider-by-name", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -24535,33 +33553,33 @@ } }, { - "description": "When true, the handler blocks until the run completes and returns 200 with the full Run. When false (default), the handler returns 202 Accepted immediately.", - "explode": false, - "in": "query", - "name": "wait", + "description": "Provider name.", + "in": "path", + "name": "name", + "required": true, "schema": { - "description": "When true, the handler blocks until the run completes and returns 200 with the full Run. When false (default), the handler returns 202 Accepted immediately.", - "type": "boolean" + "description": "Provider name.", + "type": "string" } } ], "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MaintenanceTriggerBody" + "$ref": "#/components/schemas/OKResponseBody" } } }, - "description": "Accepted", + "description": "OK", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -24569,57 +33587,44 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Trigger a Dolt store maintenance run" - } - }, - "/v0/city/{cityName}/maintenance/status": { - "get": { - "operationId": "get-v0-city-by-city-name-maintenance-status", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { + }, + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/MaintenanceStatusBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unauthorized", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { "schema": { - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Forbidden", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -24627,71 +33632,59 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name maintenance status" - } - }, - "/v0/city/{cityName}/order/history/{bead_id}": { - "get": { - "operationId": "get-v0-city-by-city-name-order-history-by-bead-id", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Bead ID for the order run.", - "in": "path", - "name": "bead_id", - "required": true, - "schema": { - "description": "Bead ID for the order run.", - "type": "string" + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "Store reference for disambiguating store-local bead IDs.", - "explode": false, - "in": "query", - "name": "store_ref", - "schema": { - "description": "Store reference for disambiguating store-local bead IDs.", - "type": "string" + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } - } - ], - "responses": { - "200": { + }, + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OrderHistoryDetailResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "501": { "content": { "application/problem+json": { "schema": { @@ -24699,7 +33692,7 @@ } } }, - "description": "Error", + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24707,12 +33700,10 @@ } } }, - "summary": "Get v0 city by city name order history by bead ID" - } - }, - "/v0/city/{cityName}/order/{name}": { + "summary": "Delete v0 city by city name provider by name" + }, "get": { - "operationId": "get-v0-city-by-city-name-order-by-name", + "operationId": "get-v0-city-by-city-name-provider-by-name", "parameters": [ { "description": "City name.", @@ -24727,12 +33718,12 @@ } }, { - "description": "Order name or scoped name.", + "description": "Provider name.", "in": "path", "name": "name", "required": true, "schema": { - "description": "Order name or scoped name.", + "description": "Provider name.", "type": "string" } } @@ -24742,18 +33733,33 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrderResponse" + "$ref": "#/components/schemas/ProviderResponse" } } }, "description": "OK", "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -24761,7 +33767,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24769,12 +33805,10 @@ } } }, - "summary": "Get v0 city by city name order by name" - } - }, - "/v0/city/{cityName}/order/{name}/disable": { - "post": { - "operationId": "post-v0-city-by-city-name-order-by-name-disable", + "summary": "Get v0 city by city name provider by name" + }, + "patch": { + "operationId": "patch-v0-city-by-city-name-provider-by-name", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -24800,16 +33834,26 @@ } }, { - "description": "Order name or scoped name.", + "description": "Provider name.", "in": "path", "name": "name", "required": true, "schema": { - "description": "Order name or scoped name.", + "description": "Provider name.", "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderUpdateInputBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { @@ -24826,7 +33870,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -24834,7 +33878,112 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24842,24 +33991,13 @@ } } }, - "summary": "Post v0 city by city name order by name disable" + "summary": "Patch v0 city by city name provider by name" } }, - "/v0/city/{cityName}/order/{name}/enable": { - "post": { - "operationId": "post-v0-city-by-city-name-order-by-name-enable", + "/v0/city/{cityName}/providers": { + "get": { + "operationId": "get-v0-city-by-city-name-providers", "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, { "description": "City name.", "in": "path", @@ -24871,16 +34009,6 @@ "pattern": "\\S", "type": "string" } - }, - { - "description": "Order name or scoped name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Order name or scoped name.", - "type": "string" - } } ], "responses": { @@ -24888,18 +34016,33 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ListBodyProviderResponse" } } }, "description": "OK", "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -24907,7 +34050,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -24915,12 +34088,10 @@ } } }, - "summary": "Post v0 city by city name order by name enable" - } - }, - "/v0/city/{cityName}/order/{name}/run": { + "summary": "Get v0 city by city name providers" + }, "post": { - "operationId": "post-v0-city-by-city-name-order-by-name-run", + "operationId": "create-provider", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -24944,45 +34115,35 @@ "pattern": "\\S", "type": "string" } - }, - { - "description": "Order name or scoped name of a trigger=\"webhook\" order.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Order name or scoped name of a trigger=\"webhook\" order.", - "type": "string" - } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrderRunInputBody" + "$ref": "#/components/schemas/ProviderCreateInputBody" } } }, "required": true }, "responses": { - "202": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrderRunOutputBody" + "$ref": "#/components/schemas/ProviderCreatedOutputBody" } } }, - "description": "Accepted", + "description": "Created", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -24990,51 +34151,29 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name order by name run" - } - }, - "/v0/city/{cityName}/orders": { - "get": { - "operationId": "get-v0-city-by-city-name-orders", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { + }, + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OrderListBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "403": { "content": { "application/problem+json": { "schema": { @@ -25042,61 +34181,29 @@ } } }, - "description": "Error", + "description": "Forbidden", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name orders" - } - }, - "/v0/city/{cityName}/orders/check": { - "get": { - "operationId": "get-v0-city-by-city-name-orders-check", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Bypass cached order-check responses and cached order history.", - "explode": false, - "in": "query", - "name": "fresh", - "schema": { - "description": "Bypass cached order-check responses and cached order history.", - "type": "boolean" - } - } - ], - "responses": { - "200": { + "404": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OrderCheckListBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "409": { "content": { "application/problem+json": { "schema": { @@ -25104,83 +34211,44 @@ } } }, - "description": "Error", + "description": "Conflict", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name orders check" - } - }, - "/v0/city/{cityName}/orders/feed": { - "get": { - "operationId": "get-v0-city-by-city-name-orders-feed", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Scope kind (city or rig).", - "explode": false, - "in": "query", - "name": "scope_kind", - "schema": { - "description": "Scope kind (city or rig).", - "type": "string" - } }, - { - "description": "Scope reference.", - "explode": false, - "in": "query", - "name": "scope_ref", - "schema": { - "description": "Scope reference.", - "type": "string" + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "Maximum number of feed items to return.", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "description": "Maximum number of feed items to return.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - } - ], - "responses": { - "200": { + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OrdersFeedBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "501": { "content": { "application/problem+json": { "schema": { @@ -25188,7 +34256,7 @@ } } }, - "description": "Error", + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -25196,12 +34264,12 @@ } } }, - "summary": "Get v0 city by city name orders feed" + "summary": "Create a provider" } }, - "/v0/city/{cityName}/orders/history": { + "/v0/city/{cityName}/providers/public": { "get": { - "operationId": "get-v0-city-by-city-name-orders-history", + "operationId": "get-v0-city-by-city-name-providers-public", "parameters": [ { "description": "City name.", @@ -25214,40 +34282,6 @@ "pattern": "\\S", "type": "string" } - }, - { - "description": "Scoped order name.", - "explode": false, - "in": "query", - "name": "scoped_name", - "required": true, - "schema": { - "description": "Scoped order name.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "Maximum number of history entries. 0 = default.", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "description": "Maximum number of history entries. 0 = default.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, - { - "description": "Return entries before this RFC3339 timestamp.", - "explode": false, - "in": "query", - "name": "before", - "schema": { - "description": "Return entries before this RFC3339 timestamp.", - "type": "string" - } } ], "responses": { @@ -25255,17 +34289,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrderHistoryListBody" + "$ref": "#/components/schemas/ProviderPublicListBody" } } }, "description": "OK", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Index": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" } }, "X-GC-Request-Id": { @@ -25273,7 +34308,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -25281,7 +34316,37 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -25289,12 +34354,12 @@ } } }, - "summary": "Get v0 city by city name orders history" + "summary": "Get v0 city by city name providers public" } }, - "/v0/city/{cityName}/packs": { + "/v0/city/{cityName}/readiness": { "get": { - "operationId": "get-v0-city-by-city-name-packs", + "operationId": "get-v0-city-by-city-name-readiness", "parameters": [ { "description": "City name.", @@ -25302,11 +34367,31 @@ "name": "cityName", "required": true, "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", + "description": "City name.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + } + }, + { + "description": "Comma-separated readiness items to check (default: claude,codex,gemini,github_cli).", + "explode": false, + "in": "query", + "name": "items", + "schema": { + "description": "Comma-separated readiness items to check (default: claude,codex,gemini,github_cli).", "type": "string" } + }, + { + "description": "Force fresh probe, bypassing cache.", + "explode": false, + "in": "query", + "name": "fresh", + "schema": { + "description": "Force fresh probe, bypassing cache.", + "type": "boolean" + } } ], "responses": { @@ -25314,7 +34399,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackListBody" + "$ref": "#/components/schemas/ReadinessResponse" } } }, @@ -25325,7 +34410,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -25333,71 +34418,44 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name packs" - }, - "post": { - "description": "Imports a pack into the city by source (a remote git URL or registry ref), resolving + installing it so its templates compose into the city.", - "operationId": "add-pack", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PackAddInputBody" + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "201": { + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/PackAddedOutputBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "Created", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -25405,7 +34463,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -25413,12 +34471,12 @@ } } }, - "summary": "Add a pack" + "summary": "Get v0 city by city name readiness" } }, - "/v0/city/{cityName}/packs/{name}": { + "/v0/city/{cityName}/rig/{name}": { "delete": { - "operationId": "delete-v0-city-by-city-name-packs-by-name", + "operationId": "delete-v0-city-by-city-name-rig-by-name", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -25444,12 +34502,12 @@ } }, { - "description": "The import binding name to remove (the [imports.\u003cname\u003e] key).", + "description": "Rig name.", "in": "path", "name": "name", "required": true, "schema": { - "description": "The import binding name to remove (the [imports.\u003cname\u003e] key).", + "description": "Rig name.", "type": "string" } } @@ -25459,7 +34517,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PackRemovedOutputBody" + "$ref": "#/components/schemas/OKResponseBody" } } }, @@ -25470,7 +34528,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -25478,72 +34536,29 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Delete v0 city by city name packs by name" - } - }, - "/v0/city/{cityName}/patches/agent/{base}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-patches-agent-by-base", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Agent patch name (unqualified).", - "in": "path", - "name": "base", - "required": true, - "schema": { - "description": "Agent patch name (unqualified).", - "type": "string" - } - } - ], - "responses": { - "200": { + "401": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/PatchDeletedResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "403": { "content": { "application/problem+json": { "schema": { @@ -25551,74 +34566,29 @@ } } }, - "description": "Error", + "description": "Forbidden", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Delete v0 city by city name patches agent by base" - }, - "get": { - "operationId": "get-v0-city-by-city-name-patches-agent-by-base", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Agent patch name (unqualified).", - "in": "path", - "name": "base", - "required": true, - "schema": { - "description": "Agent patch name (unqualified).", - "type": "string" - } - } - ], - "responses": { - "200": { + "404": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/AgentPatch" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Not Found", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "422": { "content": { "application/problem+json": { "schema": { @@ -25626,82 +34596,29 @@ } } }, - "description": "Error", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name patches agent by base" - } - }, - "/v0/city/{cityName}/patches/agent/{dir}/{base}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-patches-agent-by-dir-by-base", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Agent directory (rig name).", - "in": "path", - "name": "dir", - "required": true, - "schema": { - "description": "Agent directory (rig name).", - "type": "string" - } }, - { - "description": "Agent base name.", - "in": "path", - "name": "base", - "required": true, - "schema": { - "description": "Agent base name.", - "type": "string" - } - } - ], - "responses": { - "200": { + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/PatchDeletedResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "501": { "content": { "application/problem+json": { "schema": { @@ -25709,7 +34626,7 @@ } } }, - "description": "Error", + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -25717,10 +34634,10 @@ } } }, - "summary": "Delete v0 city by city name patches agent by dir by base" + "summary": "Delete v0 city by city name rig by name" }, "get": { - "operationId": "get-v0-city-by-city-name-patches-agent-by-dir-by-base", + "operationId": "get-v0-city-by-city-name-rig-by-name", "parameters": [ { "description": "City name.", @@ -25735,23 +34652,23 @@ } }, { - "description": "Agent directory (rig name).", + "description": "Rig name.", "in": "path", - "name": "dir", + "name": "name", "required": true, "schema": { - "description": "Agent directory (rig name).", + "description": "Rig name.", "type": "string" } }, { - "description": "Agent base name.", - "in": "path", - "name": "base", - "required": true, + "description": "Include git status.", + "explode": false, + "in": "query", + "name": "git", "schema": { - "description": "Agent base name.", - "type": "string" + "description": "Include git status.", + "type": "boolean" } } ], @@ -25760,7 +34677,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AgentPatch" + "$ref": "#/components/schemas/RigResponse" } } }, @@ -25786,7 +34703,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -25794,66 +34711,14 @@ } } }, - "description": "Error", - "headers": { - "X-GC-Request-Id": { - "$ref": "#/components/headers/X-GC-Request-Id" - } - } - } - }, - "summary": "Get v0 city by city name patches agent by dir by base" - } - }, - "/v0/city/{cityName}/patches/agents": { - "get": { - "operationId": "get-v0-city-by-city-name-patches-agents", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListBodyAgentPatch" - } - } - }, - "description": "OK", + "description": "Not Found", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "422": { "content": { "application/problem+json": { "schema": { @@ -25861,70 +34726,14 @@ } } }, - "description": "Error", - "headers": { - "X-GC-Request-Id": { - "$ref": "#/components/headers/X-GC-Request-Id" - } - } - } - }, - "summary": "Get v0 city by city name patches agents" - }, - "put": { - "operationId": "put-v0-city-by-city-name-patches-agents", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentPatchSetInputBody" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchOKResponseBody" - } - } - }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -25932,7 +34741,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -25940,12 +34749,10 @@ } } }, - "summary": "Put v0 city by city name patches agents" - } - }, - "/v0/city/{cityName}/patches/provider/{name}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-patches-provider-by-name", + "summary": "Get v0 city by city name rig by name" + }, + "patch": { + "operationId": "patch-v0-city-by-city-name-rig-by-name", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -25971,22 +34778,32 @@ } }, { - "description": "Provider patch name.", + "description": "Rig name.", "in": "path", "name": "name", "required": true, "schema": { - "description": "Provider patch name.", + "description": "Rig name.", "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RigUpdateInputBody" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchDeletedResponseBody" + "$ref": "#/components/schemas/OKResponseBody" } } }, @@ -25997,7 +34814,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -26005,74 +34822,14 @@ } } }, - "description": "Error", - "headers": { - "X-GC-Request-Id": { - "$ref": "#/components/headers/X-GC-Request-Id" - } - } - } - }, - "summary": "Delete v0 city by city name patches provider by name" - }, - "get": { - "operationId": "get-v0-city-by-city-name-patches-provider-by-name", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Provider patch name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Provider patch name.", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProviderPatch" - } - } - }, - "description": "OK", + "description": "Bad Request", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -26080,66 +34837,29 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name patches provider by name" - } - }, - "/v0/city/{cityName}/patches/providers": { - "get": { - "operationId": "get-v0-city-by-city-name-patches-providers", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { + }, + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ListBodyProviderPatch" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Forbidden", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -26147,70 +34867,44 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name patches providers" - }, - "put": { - "operationId": "put-v0-city-by-city-name-patches-providers", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProviderPatchSetInputBody" + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "200": { + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/PatchOKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "501": { "content": { "application/problem+json": { "schema": { @@ -26218,7 +34912,7 @@ } } }, - "description": "Error", + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26226,12 +34920,12 @@ } } }, - "summary": "Put v0 city by city name patches providers" + "summary": "Patch v0 city by city name rig by name" } }, - "/v0/city/{cityName}/patches/rig/{name}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-patches-rig-by-name", + "/v0/city/{cityName}/rig/{name}/{action}": { + "post": { + "operationId": "post-v0-city-by-city-name-rig-by-name-by-action", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -26257,12 +34951,27 @@ } }, { - "description": "Rig patch name.", + "description": "Rig name.", "in": "path", "name": "name", "required": true, "schema": { - "description": "Rig patch name.", + "description": "Rig name.", + "type": "string" + } + }, + { + "description": "Action to perform.", + "in": "path", + "name": "action", + "required": true, + "schema": { + "description": "Action to perform.", + "enum": [ + "suspend", + "resume", + "restart" + ], "type": "string" } } @@ -26272,7 +34981,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchDeletedResponseBody" + "$ref": "#/components/schemas/RigActionBody" } } }, @@ -26283,7 +34992,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -26291,7 +35000,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26299,10 +35083,12 @@ } } }, - "summary": "Delete v0 city by city name patches rig by name" - }, + "summary": "Post v0 city by city name rig by name by action" + } + }, + "/v0/city/{cityName}/rigs": { "get": { - "operationId": "get-v0-city-by-city-name-patches-rig-by-name", + "operationId": "get-v0-city-by-city-name-rigs", "parameters": [ { "description": "City name.", @@ -26317,14 +35103,34 @@ } }, { - "description": "Rig patch name.", - "in": "path", - "name": "name", - "required": true, + "description": "Event sequence number; when provided, blocks until a newer event arrives.", + "explode": false, + "in": "query", + "name": "index", "schema": { - "description": "Rig patch name.", + "description": "Event sequence number; when provided, blocks until a newer event arrives.", + "type": "string" + } + }, + { + "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", + "explode": false, + "in": "query", + "name": "wait", + "schema": { + "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", "type": "string" } + }, + { + "description": "Include git status.", + "explode": false, + "in": "query", + "name": "git", + "schema": { + "description": "Include git status.", + "type": "boolean" + } } ], "responses": { @@ -26332,7 +35138,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RigPatch" + "$ref": "#/components/schemas/ListBodyRigResponse" } } }, @@ -26358,7 +35164,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -26366,66 +35172,44 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name patches rig by name" - } - }, - "/v0/city/{cityName}/patches/rigs": { - "get": { - "operationId": "get-v0-city-by-city-name-patches-rigs", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { + }, + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ListBodyRigPatch" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Internal Server Error", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -26433,7 +35217,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26441,10 +35225,10 @@ } } }, - "summary": "Get v0 city by city name patches rigs" + "summary": "Get v0 city by city name rigs" }, - "put": { - "operationId": "put-v0-city-by-city-name-patches-rigs", + "post": { + "operationId": "create-rig", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -26474,29 +35258,29 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RigPatchSetInputBody" + "$ref": "#/components/schemas/RigCreateInputBody" } } }, "required": true }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PatchOKResponseBody" + "$ref": "#/components/schemas/RigCreatedOutputBody" } } }, - "description": "OK", + "description": "Created", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -26504,7 +35288,112 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26512,12 +35401,12 @@ } } }, - "summary": "Put v0 city by city name patches rigs" + "summary": "Create a rig" } }, - "/v0/city/{cityName}/pending": { + "/v0/city/{cityName}/service/{name}": { "get": { - "operationId": "get-v0-city-by-city-name-pending", + "operationId": "get-v0-city-by-city-name-service-by-name", "parameters": [ { "description": "City name.", @@ -26530,6 +35419,16 @@ "pattern": "\\S", "type": "string" } + }, + { + "description": "Service name.", + "in": "path", + "name": "name", + "required": true, + "schema": { + "description": "Service name.", + "type": "string" + } } ], "responses": { @@ -26537,7 +35436,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListBodyCityPendingEntry" + "$ref": "#/components/schemas/Status" } } }, @@ -26563,7 +35462,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -26571,71 +35470,29 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name pending" - } - }, - "/v0/city/{cityName}/provider-readiness": { - "get": { - "operationId": "get-v0-city-by-city-name-provider-readiness", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Comma-separated provider names to check (default: claude,codex,gemini).", - "explode": false, - "in": "query", - "name": "providers", - "schema": { - "description": "Comma-separated provider names to check (default: claude,codex,gemini).", - "type": "string" - } }, - { - "description": "Force fresh probe, bypassing cache.", - "explode": false, - "in": "query", - "name": "fresh", - "schema": { - "description": "Force fresh probe, bypassing cache.", - "type": "boolean" - } - } - ], - "responses": { - "200": { + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProviderReadinessResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -26643,7 +35500,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26651,12 +35508,12 @@ } } }, - "summary": "Get v0 city by city name provider readiness" + "summary": "Get v0 city by city name service by name" } }, - "/v0/city/{cityName}/provider/{name}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-provider-by-name", + "/v0/city/{cityName}/service/{name}/restart": { + "post": { + "operationId": "post-v0-city-by-city-name-service-by-name-restart", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -26682,12 +35539,12 @@ } }, { - "description": "Provider name.", + "description": "Service name.", "in": "path", "name": "name", "required": true, "schema": { - "description": "Provider name.", + "description": "Service name.", "type": "string" } } @@ -26697,18 +35554,78 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ServiceRestartOutputBody" + } + } + }, + "description": "OK", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -26716,7 +35633,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26724,10 +35641,12 @@ } } }, - "summary": "Delete v0 city by city name provider by name" - }, + "summary": "Post v0 city by city name service by name restart" + } + }, + "/v0/city/{cityName}/services": { "get": { - "operationId": "get-v0-city-by-city-name-provider-by-name", + "operationId": "get-v0-city-by-city-name-services", "parameters": [ { "description": "City name.", @@ -26740,16 +35659,6 @@ "pattern": "\\S", "type": "string" } - }, - { - "description": "Provider name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Provider name.", - "type": "string" - } } ], "responses": { @@ -26757,7 +35666,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProviderResponse" + "$ref": "#/components/schemas/ListBodyStatus" } } }, @@ -26783,7 +35692,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -26791,80 +35700,29 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name provider by name" - }, - "patch": { - "operationId": "patch-v0-city-by-city-name-provider-by-name", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Provider name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Provider name.", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProviderUpdateInputBody" - } - } }, - "required": true - }, - "responses": { - "200": { + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -26872,7 +35730,7 @@ } } }, - "description": "Error", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26880,12 +35738,12 @@ } } }, - "summary": "Patch v0 city by city name provider by name" + "summary": "Get v0 city by city name services" } }, - "/v0/city/{cityName}/providers": { + "/v0/city/{cityName}/session/{id}": { "get": { - "operationId": "get-v0-city-by-city-name-providers", + "operationId": "get-v0-city-by-city-name-session-by-id", "parameters": [ { "description": "City name.", @@ -26898,6 +35756,39 @@ "pattern": "\\S", "type": "string" } + }, + { + "description": "Session ID, alias, or runtime session_name.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Session ID, alias, or runtime session_name.", + "type": "string" + } + }, + { + "description": "Include last output preview.", + "explode": false, + "in": "query", + "name": "peek", + "schema": { + "description": "Include last output preview.", + "type": "boolean" + } + }, + { + "description": "Number of lines to include in the last output preview when peek=true. Defaults to 5.", + "explode": false, + "in": "query", + "name": "peek_lines", + "schema": { + "description": "Number of lines to include in the last output preview when peek=true. Defaults to 5.", + "format": "int64", + "maximum": 10000, + "minimum": 0, + "type": "integer" + } } ], "responses": { @@ -26905,7 +35796,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListBodyProviderResponse" + "$ref": "#/components/schemas/SessionResponse" } } }, @@ -26931,7 +35822,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -26939,7 +35830,67 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -26947,10 +35898,10 @@ } } }, - "summary": "Get v0 city by city name providers" + "summary": "Get v0 city by city name session by ID" }, - "post": { - "operationId": "create-provider", + "patch": { + "operationId": "patch-v0-city-by-city-name-session-by-id", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -26974,35 +35925,60 @@ "pattern": "\\S", "type": "string" } + }, + { + "description": "Session ID, alias, or runtime session_name.", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "Session ID, alias, or runtime session_name.", + "type": "string" + } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProviderCreateInputBody" + "$ref": "#/components/schemas/SessionPatchBody" } } }, "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProviderCreatedOutputBody" + "$ref": "#/components/schemas/SessionResponse" } } }, - "description": "Created", + "description": "OK", "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -27010,59 +35986,44 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Create a provider" - } - }, - "/v0/city/{cityName}/providers/public": { - "get": { - "operationId": "get-v0-city-by-city-name-providers-public", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProviderPublicListBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Forbidden", "headers": { - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -27070,71 +36031,29 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name providers public" - } - }, - "/v0/city/{cityName}/readiness": { - "get": { - "operationId": "get-v0-city-by-city-name-readiness", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Comma-separated readiness items to check (default: claude,codex,gemini,github_cli).", - "explode": false, - "in": "query", - "name": "items", - "schema": { - "description": "Comma-separated readiness items to check (default: claude,codex,gemini,github_cli).", - "type": "string" - } }, - { - "description": "Force fresh probe, bypassing cache.", - "explode": false, - "in": "query", - "name": "fresh", - "schema": { - "description": "Force fresh probe, bypassing cache.", - "type": "boolean" - } - } - ], - "responses": { - "200": { + "409": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ReadinessResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Conflict", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "422": { "content": { "application/problem+json": { "schema": { @@ -27142,72 +36061,29 @@ } } }, - "description": "Error", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name readiness" - } - }, - "/v0/city/{cityName}/rig/{name}": { - "delete": { - "operationId": "delete-v0-city-by-city-name-rig-by-name", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Rig name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Rig name.", - "type": "string" - } - } - ], - "responses": { - "200": { + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -27215,7 +36091,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -27223,10 +36099,12 @@ } } }, - "summary": "Delete v0 city by city name rig by name" - }, + "summary": "Patch v0 city by city name session by ID" + } + }, + "/v0/city/{cityName}/session/{id}/agents": { "get": { - "operationId": "get-v0-city-by-city-name-rig-by-name", + "operationId": "get-v0-city-by-city-name-session-by-id-agents", "parameters": [ { "description": "City name.", @@ -27241,24 +36119,14 @@ } }, { - "description": "Rig name.", + "description": "Session ID, alias, or runtime session_name.", "in": "path", - "name": "name", + "name": "id", "required": true, "schema": { - "description": "Rig name.", + "description": "Session ID, alias, or runtime session_name.", "type": "string" } - }, - { - "description": "Include git status.", - "explode": false, - "in": "query", - "name": "git", - "schema": { - "description": "Include git status.", - "type": "boolean" - } } ], "responses": { @@ -27266,7 +36134,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RigResponse" + "$ref": "#/components/schemas/SessionAgentListResponse" } } }, @@ -27292,7 +36160,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -27300,80 +36168,29 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name rig by name" - }, - "patch": { - "operationId": "patch-v0-city-by-city-name-rig-by-name", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Rig name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Rig name.", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RigUpdateInputBody" - } - } }, - "required": true - }, - "responses": { - "200": { + "409": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Conflict", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "422": { "content": { "application/problem+json": { "schema": { @@ -27381,82 +36198,29 @@ } } }, - "description": "Error", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Patch v0 city by city name rig by name" - } - }, - "/v0/city/{cityName}/rig/{name}/{action}": { - "post": { - "operationId": "post-v0-city-by-city-name-rig-by-name-by-action", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Rig name.", - "in": "path", - "name": "name", - "required": true, - "schema": { - "description": "Rig name.", - "type": "string" - } - }, - { - "description": "Action to perform (suspend, resume, restart).", - "in": "path", - "name": "action", - "required": true, - "schema": { - "description": "Action to perform (suspend, resume, restart).", - "type": "string" - } - } - ], - "responses": { - "200": { + }, + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/RigActionBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -27464,7 +36228,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -27472,12 +36236,12 @@ } } }, - "summary": "Post v0 city by city name rig by name by action" + "summary": "Get v0 city by city name session by ID agents" } }, - "/v0/city/{cityName}/rigs": { + "/v0/city/{cityName}/session/{id}/agents/{agentId}": { "get": { - "operationId": "get-v0-city-by-city-name-rigs", + "operationId": "get-v0-city-by-city-name-session-by-id-agents-by-agent-id", "parameters": [ { "description": "City name.", @@ -27492,34 +36256,24 @@ } }, { - "description": "Event sequence number; when provided, blocks until a newer event arrives.", - "explode": false, - "in": "query", - "name": "index", + "description": "Session ID, alias, or runtime session_name.", + "in": "path", + "name": "id", + "required": true, "schema": { - "description": "Event sequence number; when provided, blocks until a newer event arrives.", + "description": "Session ID, alias, or runtime session_name.", "type": "string" } }, { - "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", - "explode": false, - "in": "query", - "name": "wait", + "description": "Subagent ID within the session.", + "in": "path", + "name": "agentId", + "required": true, "schema": { - "description": "How long to block waiting for changes (Go duration string, e.g. 30s). Default 30s, max 2m.", + "description": "Subagent ID within the session.", "type": "string" } - }, - { - "description": "Include git status.", - "explode": false, - "in": "query", - "name": "git", - "schema": { - "description": "Include git status.", - "type": "boolean" - } } ], "responses": { @@ -27527,7 +36281,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListBodyRigResponse" + "$ref": "#/components/schemas/SessionAgentGetResponse" } } }, @@ -27553,7 +36307,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -27561,70 +36315,74 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name rigs" - }, - "post": { - "operationId": "create-rig", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RigCreateInputBody" + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "201": { + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/RigCreatedOutputBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "Created", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -27632,7 +36390,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -27640,13 +36398,24 @@ } } }, - "summary": "Create a rig" + "summary": "Get v0 city by city name session by ID agents by agent ID" } }, - "/v0/city/{cityName}/service/{name}": { - "get": { - "operationId": "get-v0-city-by-city-name-service-by-name", + "/v0/city/{cityName}/session/{id}/close": { + "post": { + "operationId": "post-v0-city-by-city-name-session-by-id-close", "parameters": [ + { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "in": "header", + "name": "X-GC-Request", + "required": true, + "schema": { + "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", + "minLength": 1, + "type": "string" + } + }, { "description": "City name.", "in": "path", @@ -27660,14 +36429,24 @@ } }, { - "description": "Service name.", + "description": "Session ID, alias, or runtime session_name.", "in": "path", - "name": "name", + "name": "id", "required": true, "schema": { - "description": "Service name.", + "description": "Session ID, alias, or runtime session_name.", "type": "string" } + }, + { + "description": "Permanently delete bead after closing.", + "explode": false, + "in": "query", + "name": "delete", + "schema": { + "description": "Permanently delete bead after closing.", + "type": "boolean" + } } ], "responses": { @@ -27675,33 +36454,48 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Status" + "$ref": "#/components/schemas/OKResponseBody" } } }, "description": "OK", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Forbidden", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -27709,7 +36503,67 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -27717,12 +36571,12 @@ } } }, - "summary": "Get v0 city by city name service by name" + "summary": "Post v0 city by city name session by ID close" } }, - "/v0/city/{cityName}/service/{name}/restart": { + "/v0/city/{cityName}/session/{id}/kill": { "post": { - "operationId": "post-v0-city-by-city-name-service-by-name-restart", + "operationId": "post-v0-city-by-city-name-session-by-id-kill", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -27748,12 +36602,12 @@ } }, { - "description": "Service name.", + "description": "Session ID, alias, or runtime session_name.", "in": "path", - "name": "name", + "name": "id", "required": true, "schema": { - "description": "Service name.", + "description": "Session ID, alias, or runtime session_name.", "type": "string" } } @@ -27763,7 +36617,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ServiceRestartOutputBody" + "$ref": "#/components/schemas/OKWithIDResponseBody" } } }, @@ -27774,7 +36628,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -27782,66 +36636,29 @@ } } }, - "description": "Error", + "description": "Unauthorized", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name service by name restart" - } - }, - "/v0/city/{cityName}/services": { - "get": { - "operationId": "get-v0-city-by-city-name-services", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - } - ], - "responses": { - "200": { + }, + "403": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ListBodyStatus" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Forbidden", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -27849,99 +36666,59 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name services" - } - }, - "/v0/city/{cityName}/session/{id}": { - "get": { - "operationId": "get-v0-city-by-city-name-session-by-id", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Session ID, alias, or runtime session_name.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Session ID, alias, or runtime session_name.", - "type": "string" - } - }, - { - "description": "Include last output preview.", - "explode": false, - "in": "query", - "name": "peek", - "schema": { - "description": "Include last output preview.", - "type": "boolean" - } }, - { - "description": "Number of lines to include in the last output preview when peek=true. Defaults to 5.", - "explode": false, - "in": "query", - "name": "peek_lines", - "schema": { - "description": "Number of lines to include in the last output preview when peek=true. Defaults to 5.", - "format": "int64", - "maximum": 10000, - "minimum": 0, - "type": "integer" - } - } - ], - "responses": { - "200": { + "409": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/SessionResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Conflict", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Internal Server Error", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -27949,7 +36726,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -27957,10 +36734,12 @@ } } }, - "summary": "Get v0 city by city name session by ID" - }, - "patch": { - "operationId": "patch-v0-city-by-city-name-session-by-id", + "summary": "Post v0 city by city name session by ID kill" + } + }, + "/v0/city/{cityName}/session/{id}/messages": { + "post": { + "operationId": "send-session-message", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -28000,44 +36779,59 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionPatchBody" + "$ref": "#/components/schemas/SessionMessageInputBody" } } }, "required": true }, "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionResponse" + "$ref": "#/components/schemas/AsyncAcceptedBody" } } }, - "description": "OK", + "description": "Accepted", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Forbidden", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -28045,76 +36839,44 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Patch v0 city by city name session by ID" - } - }, - "/v0/city/{cityName}/session/{id}/agents": { - "get": { - "operationId": "get-v0-city-by-city-name-session-by-id-agents", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Session ID, alias, or runtime session_name.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Session ID, alias, or runtime session_name.", - "type": "string" + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } - } - ], - "responses": { - "200": { + }, + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/SessionAgentListResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { - "X-GC-Cache-Age-S": { - "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" - } - }, - "X-GC-Index": { - "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" - } - }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -28122,7 +36884,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -28130,12 +36892,12 @@ } } }, - "summary": "Get v0 city by city name session by ID agents" + "summary": "Send a message to a session" } }, - "/v0/city/{cityName}/session/{id}/agents/{agentId}": { + "/v0/city/{cityName}/session/{id}/pending": { "get": { - "operationId": "get-v0-city-by-city-name-session-by-id-agents-by-agent-id", + "operationId": "get-v0-city-by-city-name-session-by-id-pending", "parameters": [ { "description": "City name.", @@ -28158,16 +36920,6 @@ "description": "Session ID, alias, or runtime session_name.", "type": "string" } - }, - { - "description": "Subagent ID within the session.", - "in": "path", - "name": "agentId", - "required": true, - "schema": { - "description": "Subagent ID within the session.", - "type": "string" - } } ], "responses": { @@ -28175,7 +36927,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionAgentGetResponse" + "$ref": "#/components/schemas/SessionPendingResponse" } } }, @@ -28201,7 +36953,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -28209,82 +36961,29 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Get v0 city by city name session by ID agents by agent ID" - } - }, - "/v0/city/{cityName}/session/{id}/close": { - "post": { - "operationId": "post-v0-city-by-city-name-session-by-id-close", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } - }, - { - "description": "Session ID, alias, or runtime session_name.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Session ID, alias, or runtime session_name.", - "type": "string" - } }, - { - "description": "Permanently delete bead after closing.", - "explode": false, - "in": "query", - "name": "delete", - "schema": { - "description": "Permanently delete bead after closing.", - "type": "boolean" - } - } - ], - "responses": { - "200": { + "409": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Conflict", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "422": { "content": { "application/problem+json": { "schema": { @@ -28292,72 +36991,29 @@ } } }, - "description": "Error", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name session by ID close" - } - }, - "/v0/city/{cityName}/session/{id}/kill": { - "post": { - "operationId": "post-v0-city-by-city-name-session-by-id-kill", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } - }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" - } }, - { - "description": "Session ID, alias, or runtime session_name.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Session ID, alias, or runtime session_name.", - "type": "string" - } - } - ], - "responses": { - "200": { + "500": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/OKWithIDResponseBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -28365,7 +37021,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -28373,12 +37029,12 @@ } } }, - "summary": "Post v0 city by city name session by ID kill" + "summary": "Get v0 city by city name session by ID pending" } }, - "/v0/city/{cityName}/session/{id}/messages": { + "/v0/city/{cityName}/session/{id}/permission-mode": { "post": { - "operationId": "send-session-message", + "operationId": "post-v0-city-by-city-name-session-by-id-permission-mode", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -28418,29 +37074,44 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionMessageInputBody" + "$ref": "#/components/schemas/SessionPermissionModeBody" } } }, "required": true }, "responses": { - "202": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AsyncAcceptedBody" + "$ref": "#/components/schemas/SessionResponse" } } }, - "description": "Accepted", + "description": "OK", "headers": { + "X-GC-Cache-Age-S": { + "schema": { + "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", + "format": "double", + "type": "number" + } + }, + "X-GC-Index": { + "schema": { + "description": "Latest event sequence number.", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -28448,76 +37119,119 @@ } } }, - "description": "Error", + "description": "Bad Request", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Send a message to a session" - } - }, - "/v0/city/{cityName}/session/{id}/pending": { - "get": { - "operationId": "get-v0-city-by-city-name-session-by-id-pending", - "parameters": [ - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "Session ID, alias, or runtime session_name.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Session ID, alias, or runtime session_name.", - "type": "string" + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } - } - ], - "responses": { - "200": { + }, + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/SessionPendingResponse" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "OK", + "description": "Unprocessable Entity", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "501": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Not Implemented", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -28525,7 +37239,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -28533,12 +37247,12 @@ } } }, - "summary": "Get v0 city by city name session by ID pending" + "summary": "Post v0 city by city name session by ID permission mode" } }, - "/v0/city/{cityName}/session/{id}/permission-mode": { + "/v0/city/{cityName}/session/{id}/rename": { "post": { - "operationId": "post-v0-city-by-city-name-session-by-id-permission-mode", + "operationId": "post-v0-city-by-city-name-session-by-id-rename", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -28578,7 +37292,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionPermissionModeBody" + "$ref": "#/components/schemas/SessionRenameInputBody" } } }, @@ -28615,7 +37329,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -28623,7 +37337,112 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -28631,12 +37450,12 @@ } } }, - "summary": "Post v0 city by city name session by ID permission mode" + "summary": "Post v0 city by city name session by ID rename" } }, - "/v0/city/{cityName}/session/{id}/rename": { + "/v0/city/{cityName}/session/{id}/respond": { "post": { - "operationId": "post-v0-city-by-city-name-session-by-id-rename", + "operationId": "respond-session", "parameters": [ { "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", @@ -28676,44 +37495,59 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionRenameInputBody" + "$ref": "#/components/schemas/SessionRespondInputBody" } } }, "required": true }, "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionResponse" + "$ref": "#/components/schemas/SessionRespondOutputBody" } } }, - "description": "OK", + "description": "Accepted", "headers": { - "X-GC-Cache-Age-S": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { "schema": { - "description": "Age in seconds of the CachingStore snapshot that served this response (0 if not applicable).", - "format": "double", - "type": "number" + "$ref": "#/components/schemas/ErrorModel" } - }, - "X-GC-Index": { + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { "schema": { - "description": "Latest event sequence number.", - "format": "int64", - "minimum": 0, - "type": "integer" + "$ref": "#/components/schemas/ErrorModel" } - }, + } + }, + "description": "Forbidden", + "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -28721,82 +37555,74 @@ } } }, - "description": "Error", + "description": "Not Found", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } - } - }, - "summary": "Post v0 city by city name session by ID rename" - } - }, - "/v0/city/{cityName}/session/{id}/respond": { - "post": { - "operationId": "respond-session", - "parameters": [ - { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "in": "header", - "name": "X-GC-Request", - "required": true, - "schema": { - "description": "Anti-CSRF header required on mutation requests. Any non-empty value is accepted; the header's presence is what the server checks.", - "minLength": 1, - "type": "string" - } }, - { - "description": "City name.", - "in": "path", - "name": "cityName", - "required": true, - "schema": { - "description": "City name.", - "minLength": 1, - "pattern": "\\S", - "type": "string" + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } }, - { - "description": "Session ID, alias, or runtime session_name.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "description": "Session ID, alias, or runtime session_name.", - "type": "string" + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SessionRespondInputBody" + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "required": true - }, - "responses": { - "202": { + "501": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/SessionRespondOutputBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "Accepted", + "description": "Not Implemented", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "503": { "content": { "application/problem+json": { "schema": { @@ -28804,7 +37630,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -28869,7 +37695,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -28877,7 +37703,97 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29174,7 +38090,7 @@ } } }, - "default": { + "401": { "content": { "application/problem+json": { "schema": { @@ -29182,7 +38098,82 @@ } } }, - "description": "Error", + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29247,7 +38238,97 @@ } } }, - "default": { + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { "content": { "application/problem+json": { "schema": { @@ -29255,7 +38336,7 @@ } } }, - "description": "Error", + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29364,7 +38445,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -29372,7 +38453,67 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29437,7 +38578,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -29445,7 +38586,112 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29556,7 +38802,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -29564,7 +38810,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29609,25 +38900,100 @@ } } }, - "required": true - }, - "responses": { - "202": { + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsyncAcceptedBody" + } + } + }, + "description": "Accepted", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { "content": { - "application/json": { + "application/problem+json": { "schema": { - "$ref": "#/components/schemas/AsyncAcceptedBody" + "$ref": "#/components/schemas/ErrorModel" } } }, - "description": "Accepted", + "description": "Unprocessable Entity", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" } } }, - "default": { + "500": { "content": { "application/problem+json": { "schema": { @@ -29635,7 +39001,22 @@ } } }, - "description": "Error", + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29700,7 +39081,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -29708,7 +39089,97 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Conflict", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29797,7 +39268,7 @@ } } }, - "default": { + "404": { "content": { "application/problem+json": { "schema": { @@ -29805,7 +39276,52 @@ } } }, - "description": "Error", + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Service Unavailable", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -29961,7 +39477,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -29969,7 +39485,82 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unauthorized", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Forbidden", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" @@ -30056,7 +39647,7 @@ } } }, - "default": { + "400": { "content": { "application/problem+json": { "schema": { @@ -30064,7 +39655,52 @@ } } }, - "description": "Error", + "description": "Bad Request", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Not Found", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Unprocessable Entity", + "headers": { + "X-GC-Request-Id": { + "$ref": "#/components/headers/X-GC-Request-Id" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ErrorModel" + } + } + }, + "description": "Internal Server Error", "headers": { "X-GC-Request-Id": { "$ref": "#/components/headers/X-GC-Request-Id" diff --git a/internal/api/openapi_problem_types.go b/internal/api/openapi_problem_types.go index ee7d834920..edb2f0880e 100644 --- a/internal/api/openapi_problem_types.go +++ b/internal/api/openapi_problem_types.go @@ -1,19 +1,17 @@ package api -import "github.com/danielgtaylor/huma/v2" +import ( + "github.com/danielgtaylor/huma/v2" -const ( - slingMissingBeadProblemType = "urn:gascity:error:sling-missing-bead" - slingCrossRigProblemType = "urn:gascity:error:sling-cross-rig" - slingCrossStoreRouteProblemType = "urn:gascity:error:sling-cross-store-route" + "github.com/gastownhall/gascity/internal/api/apierr" ) -var documentedProblemTypes = []string{ - slingMissingBeadProblemType, - slingCrossRigProblemType, - slingCrossStoreRouteProblemType, -} - +// documentProblemTypes annotates the generated OpenAPI ErrorModel schema with +// the catalog of machine-readable problem-type URNs the API can return. It +// generates the `x-gascity-problem-types` extension and the `type` examples +// directly from the apierr registry (apierr.Registered()), so the published +// contract stays in lockstep with the codes the server actually mints — adding +// a catalog entry surfaces in the spec with no edit here. func documentProblemTypes(oapi *huma.OpenAPI) { if oapi == nil || oapi.Components == nil || oapi.Components.Schemas == nil { return @@ -26,15 +24,21 @@ func documentProblemTypes(oapi *huma.OpenAPI) { if typeSchema == nil { return } - for _, problemType := range documentedProblemTypes { - if !hasProblemTypeExample(typeSchema.Examples, problemType) { - typeSchema.Examples = append(typeSchema.Examples, problemType) + + urns := make([]string, 0, len(apierr.Registered())) + for _, pt := range apierr.Registered() { + urns = append(urns, pt.URN()) + } + + for _, urn := range urns { + if !hasProblemTypeExample(typeSchema.Examples, urn) { + typeSchema.Examples = append(typeSchema.Examples, urn) } } if typeSchema.Extensions == nil { typeSchema.Extensions = map[string]any{} } - typeSchema.Extensions["x-gascity-problem-types"] = append([]string(nil), documentedProblemTypes...) + typeSchema.Extensions["x-gascity-problem-types"] = urns } func hasProblemTypeExample(examples []any, problemType string) bool { diff --git a/internal/api/partial_errors.go b/internal/api/partial_errors.go index 6767ca906a..4b4a80b1a9 100644 --- a/internal/api/partial_errors.go +++ b/internal/api/partial_errors.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/danielgtaylor/huma/v2" + "github.com/gastownhall/gascity/internal/api/apierr" ) // partialAggregator collects errors from per-rig/per-backend operations @@ -76,5 +76,5 @@ func (p *partialAggregator) outageError() error { if msgs := p.messages(); len(msgs) > 0 { detail = detail + ": " + strings.Join(msgs, "; ") } - return huma.Error503ServiceUnavailable(detail) + return apierr.ServiceUnavailable.Msg(detail) } diff --git a/internal/api/supervisor_city_routes.go b/internal/api/supervisor_city_routes.go index c31fd2c4d1..ea937b666d 100644 --- a/internal/api/supervisor_city_routes.go +++ b/internal/api/supervisor_city_routes.go @@ -30,33 +30,33 @@ func sessionStreamEventMap() map[string]any { // per-request city resolution. func (sm *SupervisorMux) registerCityRoutes() { // Status + Health. - cityGet(sm, "/status", (*Server).humaHandleStatus) - cityGet(sm, "/health", (*Server).humaHandleHealth) + cityGet(sm, "/status", (*Server).humaHandleStatus, errorStatuses(http.StatusNotFound, http.StatusServiceUnavailable)) + cityGet(sm, "/health", (*Server).humaHandleHealth, errorStatuses(http.StatusNotFound)) // City detail. - cityGet(sm, "", (*Server).humaHandleCityGet) - cityPatch(sm, "", (*Server).humaHandleCityPatch) + cityGet(sm, "", (*Server).humaHandleCityGet, errorStatuses(http.StatusNotFound)) + cityPatch(sm, "", (*Server).humaHandleCityPatch, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented)) // Readiness (per-city). - cityGet(sm, "/readiness", (*Server).humaHandleReadiness) - cityGet(sm, "/provider-readiness", (*Server).humaHandleProviderReadiness) + cityGet(sm, "/readiness", (*Server).humaHandleReadiness, errorStatuses(http.StatusBadRequest, http.StatusNotFound)) + cityGet(sm, "/provider-readiness", (*Server).humaHandleProviderReadiness, errorStatuses(http.StatusBadRequest, http.StatusNotFound)) // Config. - cityGet(sm, "/config", (*Server).humaHandleConfigGet) - cityGet(sm, "/config/explain", (*Server).humaHandleConfigExplain) - cityGet(sm, "/config/validate", (*Server).humaHandleConfigValidate) - cityGet(sm, "/config/defaults", (*Server).humaHandleConfigDefaults) + cityGet(sm, "/config", (*Server).humaHandleConfigGet, errorStatuses(http.StatusNotFound)) + cityGet(sm, "/config/explain", (*Server).humaHandleConfigExplain, errorStatuses(http.StatusNotFound)) + cityGet(sm, "/config/validate", (*Server).humaHandleConfigValidate, errorStatuses(http.StatusNotFound)) + cityGet(sm, "/config/defaults", (*Server).humaHandleConfigDefaults, errorStatuses(http.StatusNotFound)) // Agents — read / CRUD. Agents can be addressed unqualified // ({base}) or rig-qualified ({dir}/{base}); there is no third // form, so two explicit routes cover every real case without a // trailing-path wildcard. The routes we register are the routes // we expose. - cityGet(sm, "/agents", (*Server).humaHandleAgentList) - cityGet(sm, "/agent/{dir}/{base}/output", (*Server).humaHandleAgentOutputQualified) - cityGet(sm, "/agent/{base}/output", (*Server).humaHandleAgentOutput) - cityGet(sm, "/agent/{dir}/{base}", (*Server).humaHandleAgentQualified) - cityGet(sm, "/agent/{base}", (*Server).humaHandleAgent) + cityGet(sm, "/agents", (*Server).humaHandleAgentList, errorStatuses(http.StatusNotFound)) + cityGet(sm, "/agent/{dir}/{base}/output", (*Server).humaHandleAgentOutputQualified, errorStatuses(http.StatusNotFound)) + cityGet(sm, "/agent/{base}/output", (*Server).humaHandleAgentOutput, errorStatuses(http.StatusNotFound)) + cityGet(sm, "/agent/{dir}/{base}", (*Server).humaHandleAgentQualified, errorStatuses(http.StatusNotFound)) + cityGet(sm, "/agent/{base}", (*Server).humaHandleAgent, errorStatuses(http.StatusNotFound)) cityRegister(sm, huma.Operation{ OperationID: "create-agent", Method: http.MethodPost, @@ -64,13 +64,14 @@ func (sm *SupervisorMux) registerCityRoutes() { Summary: "Create an agent", Description: "Creates an agent and waits until it is visible to immediate follow-up operations. If the agent is durably created but visibility confirmation is canceled or times out, the retryable 503/504 response includes a Retry-After header.", DefaultStatus: http.StatusCreated, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusNotImplemented, http.StatusServiceUnavailable, http.StatusGatewayTimeout}, }, (*Server).humaHandleAgentCreate) - cityPatch(sm, "/agent/{dir}/{base}", (*Server).humaHandleAgentUpdateQualified) - cityPatch(sm, "/agent/{base}", (*Server).humaHandleAgentUpdate) - cityDelete(sm, "/agent/{dir}/{base}", (*Server).humaHandleAgentDeleteQualified) - cityDelete(sm, "/agent/{base}", (*Server).humaHandleAgentDelete) - cityPost(sm, "/agent/{dir}/{base}/{action}", (*Server).humaHandleAgentActionQualified) - cityPost(sm, "/agent/{base}/{action}", (*Server).humaHandleAgentAction) + cityPatch(sm, "/agent/{dir}/{base}", (*Server).humaHandleAgentUpdateQualified, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusNotImplemented)) + cityPatch(sm, "/agent/{base}", (*Server).humaHandleAgentUpdate, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusNotImplemented)) + cityDelete(sm, "/agent/{dir}/{base}", (*Server).humaHandleAgentDeleteQualified, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusNotImplemented)) + cityDelete(sm, "/agent/{base}", (*Server).humaHandleAgentDelete, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusNotImplemented)) + cityPost(sm, "/agent/{dir}/{base}/{action}", (*Server).humaHandleAgentActionQualified, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented)) + cityPost(sm, "/agent/{base}/{action}", (*Server).humaHandleAgentAction, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented)) // Agent output SSE streams. agentOutputEventMap := map[string]any{ @@ -99,161 +100,176 @@ func (sm *SupervisorMux) registerCityRoutes() { sseCityStream(sm, (*Server).streamAgentOutputQualified)) // Providers. - cityGet(sm, "/providers", (*Server).humaHandleProviderList) - cityGet(sm, "/providers/public", (*Server).humaHandleProviderPublicList) - cityGet(sm, "/provider/{name}", (*Server).humaHandleProviderGet) + cityGet(sm, "/providers", (*Server).humaHandleProviderList, errorStatuses(http.StatusNotFound)) + cityGet(sm, "/providers/public", (*Server).humaHandleProviderPublicList, errorStatuses(http.StatusNotFound)) + cityGet(sm, "/provider/{name}", (*Server).humaHandleProviderGet, errorStatuses(http.StatusNotFound)) cityRegister(sm, huma.Operation{ OperationID: "create-provider", Method: http.MethodPost, Path: "/providers", Summary: "Create a provider", DefaultStatus: http.StatusCreated, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusNotImplemented}, }, (*Server).humaHandleProviderCreate) - cityPatch(sm, "/provider/{name}", (*Server).humaHandleProviderUpdate) - cityDelete(sm, "/provider/{name}", (*Server).humaHandleProviderDelete) + cityPatch(sm, "/provider/{name}", (*Server).humaHandleProviderUpdate, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusNotImplemented)) + cityDelete(sm, "/provider/{name}", (*Server).humaHandleProviderDelete, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusNotImplemented)) // Rigs. - cityGet(sm, "/rigs", (*Server).humaHandleRigList) - cityGet(sm, "/rig/{name}", (*Server).humaHandleRigGet) + cityGet(sm, "/rigs", (*Server).humaHandleRigList, errorStatuses(http.StatusNotFound, http.StatusServiceUnavailable)) + cityGet(sm, "/rig/{name}", (*Server).humaHandleRigGet, errorStatuses(http.StatusNotFound)) cityRegister(sm, huma.Operation{ OperationID: "create-rig", Method: http.MethodPost, Path: "/rigs", Summary: "Create a rig", DefaultStatus: http.StatusCreated, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusNotImplemented}, }, (*Server).humaHandleRigCreate) - cityPatch(sm, "/rig/{name}", (*Server).humaHandleRigUpdate) - cityDelete(sm, "/rig/{name}", (*Server).humaHandleRigDelete) - cityPost(sm, "/rig/{name}/{action}", (*Server).humaHandleRigAction) + cityPatch(sm, "/rig/{name}", (*Server).humaHandleRigUpdate, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented)) + cityDelete(sm, "/rig/{name}", (*Server).humaHandleRigDelete, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented)) + cityPost(sm, "/rig/{name}/{action}", (*Server).humaHandleRigAction, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented)) // Patches — agent. Same qualified/unqualified split as /agent: two // explicit routes instead of a trailing-path wildcard. - cityGet(sm, "/patches/agents", (*Server).humaHandleAgentPatchList) - cityGet(sm, "/patches/agent/{dir}/{base}", (*Server).humaHandleAgentPatchGetQualified) - cityGet(sm, "/patches/agent/{base}", (*Server).humaHandleAgentPatchGet) - cityPut(sm, "/patches/agents", (*Server).humaHandleAgentPatchSet) - cityDelete(sm, "/patches/agent/{dir}/{base}", (*Server).humaHandleAgentPatchDeleteQualified) - cityDelete(sm, "/patches/agent/{base}", (*Server).humaHandleAgentPatchDelete) + cityGet(sm, "/patches/agents", (*Server).humaHandleAgentPatchList, errorStatuses(http.StatusNotFound)) + cityGet(sm, "/patches/agent/{dir}/{base}", (*Server).humaHandleAgentPatchGetQualified, errorStatuses(http.StatusNotFound)) + cityGet(sm, "/patches/agent/{base}", (*Server).humaHandleAgentPatchGet, errorStatuses(http.StatusNotFound)) + cityPut(sm, "/patches/agents", (*Server).humaHandleAgentPatchSet, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented)) + cityDelete(sm, "/patches/agent/{dir}/{base}", (*Server).humaHandleAgentPatchDeleteQualified, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented)) + cityDelete(sm, "/patches/agent/{base}", (*Server).humaHandleAgentPatchDelete, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented)) // Patches — rig. - cityGet(sm, "/patches/rigs", (*Server).humaHandleRigPatchList) - cityGet(sm, "/patches/rig/{name}", (*Server).humaHandleRigPatchGet) - cityPut(sm, "/patches/rigs", (*Server).humaHandleRigPatchSet) - cityDelete(sm, "/patches/rig/{name}", (*Server).humaHandleRigPatchDelete) + cityGet(sm, "/patches/rigs", (*Server).humaHandleRigPatchList, errorStatuses(http.StatusNotFound)) + cityGet(sm, "/patches/rig/{name}", (*Server).humaHandleRigPatchGet, errorStatuses(http.StatusNotFound)) + cityPut(sm, "/patches/rigs", (*Server).humaHandleRigPatchSet, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented)) + cityDelete(sm, "/patches/rig/{name}", (*Server).humaHandleRigPatchDelete, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented)) // Patches — provider. - cityGet(sm, "/patches/providers", (*Server).humaHandleProviderPatchList) - cityGet(sm, "/patches/provider/{name}", (*Server).humaHandleProviderPatchGet) - cityPut(sm, "/patches/providers", (*Server).humaHandleProviderPatchSet) - cityDelete(sm, "/patches/provider/{name}", (*Server).humaHandleProviderPatchDelete) + cityGet(sm, "/patches/providers", (*Server).humaHandleProviderPatchList, errorStatuses(http.StatusNotFound)) + cityGet(sm, "/patches/provider/{name}", (*Server).humaHandleProviderPatchGet, errorStatuses(http.StatusNotFound)) + cityPut(sm, "/patches/providers", (*Server).humaHandleProviderPatchSet, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented)) + cityDelete(sm, "/patches/provider/{name}", (*Server).humaHandleProviderPatchDelete, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented)) - // Beads. - cityGet(sm, "/beads", (*Server).humaHandleBeadList) - cityGet(sm, "/beads/graph/{rootID}", (*Server).humaHandleBeadGraph) - cityGet(sm, "/beads/ready", (*Server).humaHandleBeadReady) + // Beads. The bead ops are the P12 error-contract pilot: each declares the + // error statuses it can return (Huma adds the auto 422/500) so its problem+json + // responses are enumerated in the spec and machine-branchable via the type/code + // the handler stamps through the apierr catalog. Mutations additionally declare + // 403 because the always-installed CSRF middleware (and read-only mode) reject + // a mutation with a 403 before the handler runs; reads never emit it. + cityGet(sm, "/beads", (*Server).humaHandleBeadList, errorStatuses(http.StatusNotFound, http.StatusServiceUnavailable)) + cityGet(sm, "/beads/graph/{rootID}", (*Server).humaHandleBeadGraph, errorStatuses(http.StatusNotFound)) + cityGet(sm, "/beads/ready", (*Server).humaHandleBeadReady, errorStatuses(http.StatusNotFound, http.StatusServiceUnavailable)) cityRegister(sm, huma.Operation{ OperationID: "create-bead", Method: http.MethodPost, Path: "/beads", Summary: "Create a bead", DefaultStatus: http.StatusCreated, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict}, }, (*Server).humaHandleBeadCreate) - cityGet(sm, "/bead/{id}", (*Server).humaHandleBeadGet) - cityGet(sm, "/bead/{id}/deps", (*Server).humaHandleBeadDeps) - cityPost(sm, "/bead/{id}/close", (*Server).humaHandleBeadClose) - cityPost(sm, "/bead/{id}/reopen", (*Server).humaHandleBeadReopen) - cityPost(sm, "/bead/{id}/update", (*Server).humaHandleBeadUpdate) - cityPatch(sm, "/bead/{id}", (*Server).humaHandleBeadUpdate) - cityPost(sm, "/bead/{id}/assign", (*Server).humaHandleBeadAssign) - cityDelete(sm, "/bead/{id}", (*Server).humaHandleBeadDelete) + cityGet(sm, "/bead/{id}", (*Server).humaHandleBeadGet, errorStatuses(http.StatusNotFound, http.StatusServiceUnavailable)) + cityGet(sm, "/bead/{id}/deps", (*Server).humaHandleBeadDeps, errorStatuses(http.StatusNotFound)) + cityPost(sm, "/bead/{id}/close", (*Server).humaHandleBeadClose, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict)) + cityPost(sm, "/bead/{id}/reopen", (*Server).humaHandleBeadReopen, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict)) + cityPost(sm, "/bead/{id}/update", (*Server).humaHandleBeadUpdate, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict)) + cityPatch(sm, "/bead/{id}", (*Server).humaHandleBeadUpdate, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict)) + cityPost(sm, "/bead/{id}/assign", (*Server).humaHandleBeadAssign, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict)) + cityDelete(sm, "/bead/{id}", (*Server).humaHandleBeadDelete, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict)) - // Mail. - cityGet(sm, "/mail", (*Server).humaHandleMailList) + // Mail. Part of the P12 error-contract slice (see Beads above): each op + // enumerates the error statuses it can return (Huma adds auto 422/500); + // mutations declare 403 for the CSRF/read-only middleware. + cityGet(sm, "/mail", (*Server).humaHandleMailList, errorStatuses(http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable)) cityRegister(sm, huma.Operation{ OperationID: "send-mail", Method: http.MethodPost, Path: "/mail", Summary: "Send a mail message", DefaultStatus: http.StatusCreated, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict}, }, (*Server).humaHandleMailSend) - cityGet(sm, "/mail/count", (*Server).humaHandleMailCount) - cityGet(sm, "/mail/thread/{id}", (*Server).humaHandleMailThread) - cityGet(sm, "/mail/{id}", (*Server).humaHandleMailGet) - cityPost(sm, "/mail/{id}/read", (*Server).humaHandleMailRead) - cityPost(sm, "/mail/{id}/mark-unread", (*Server).humaHandleMailMarkUnread) - cityPost(sm, "/mail/{id}/archive", (*Server).humaHandleMailArchive) + cityGet(sm, "/mail/count", (*Server).humaHandleMailCount, errorStatuses(http.StatusNotFound, http.StatusServiceUnavailable)) + cityGet(sm, "/mail/thread/{id}", (*Server).humaHandleMailThread, errorStatuses(http.StatusNotFound, http.StatusServiceUnavailable)) + cityGet(sm, "/mail/{id}", (*Server).humaHandleMailGet, errorStatuses(http.StatusNotFound, http.StatusServiceUnavailable)) + cityPost(sm, "/mail/{id}/read", (*Server).humaHandleMailRead, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound)) + cityPost(sm, "/mail/{id}/mark-unread", (*Server).humaHandleMailMarkUnread, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound)) + cityPost(sm, "/mail/{id}/archive", (*Server).humaHandleMailArchive, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound)) cityRegister(sm, huma.Operation{ OperationID: "reply-mail", Method: http.MethodPost, Path: "/mail/{id}/reply", Summary: "Reply to a mail message", DefaultStatus: http.StatusCreated, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, }, (*Server).humaHandleMailReply) - cityDelete(sm, "/mail/{id}", (*Server).humaHandleMailDelete) + cityDelete(sm, "/mail/{id}", (*Server).humaHandleMailDelete, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound)) // Convoys. - cityGet(sm, "/convoys", (*Server).humaHandleConvoyList) + cityGet(sm, "/convoys", (*Server).humaHandleConvoyList, errorStatuses(http.StatusNotFound, http.StatusServiceUnavailable)) cityRegister(sm, huma.Operation{ OperationID: "create-convoy", Method: http.MethodPost, Path: "/convoys", Summary: "Create a convoy", DefaultStatus: http.StatusCreated, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, }, (*Server).humaHandleConvoyCreate) - cityGet(sm, "/convoy/{id}", (*Server).humaHandleConvoyGet) - cityPost(sm, "/convoy/{id}/add", (*Server).humaHandleConvoyAdd) - cityPost(sm, "/convoy/{id}/remove", (*Server).humaHandleConvoyRemove) - cityGet(sm, "/convoy/{id}/check", (*Server).humaHandleConvoyCheck) - cityPost(sm, "/convoy/{id}/close", (*Server).humaHandleConvoyClose) - cityDelete(sm, "/convoy/{id}", (*Server).humaHandleConvoyDelete) + cityGet(sm, "/convoy/{id}", (*Server).humaHandleConvoyGet, errorStatuses(http.StatusNotFound, http.StatusServiceUnavailable)) + cityPost(sm, "/convoy/{id}/add", (*Server).humaHandleConvoyAdd, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound)) + cityPost(sm, "/convoy/{id}/remove", (*Server).humaHandleConvoyRemove, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound)) + cityGet(sm, "/convoy/{id}/check", (*Server).humaHandleConvoyCheck, errorStatuses(http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable)) + cityPost(sm, "/convoy/{id}/close", (*Server).humaHandleConvoyClose, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound)) + cityDelete(sm, "/convoy/{id}", (*Server).humaHandleConvoyDelete, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound)) // Events (list/emit/rotate — stream is a separate SSE registration below). - cityGet(sm, "/events", (*Server).humaHandleEventList) + cityGet(sm, "/events", (*Server).humaHandleEventList, errorStatuses(http.StatusBadRequest, http.StatusNotFound)) cityRegister(sm, huma.Operation{ OperationID: "emit-event", Method: http.MethodPost, Path: "/events", Summary: "Emit an event", DefaultStatus: http.StatusCreated, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusServiceUnavailable}, }, (*Server).humaHandleEventEmit) cityRegister(sm, huma.Operation{ OperationID: "rotate-events", Method: http.MethodPost, Path: "/events/rotate", Summary: "Force rotate the city event log", + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusMethodNotAllowed}, }, (*Server).humaHandleEventRotate) // Orders. - cityGet(sm, "/orders", (*Server).humaHandleOrderList) - cityGet(sm, "/orders/check", (*Server).humaHandleOrderCheck) - cityGet(sm, "/orders/history", (*Server).humaHandleOrderHistory) - cityGet(sm, "/order/history/{bead_id}", (*Server).humaHandleOrderHistoryDetail) - cityGet(sm, "/order/{name}", (*Server).humaHandleOrderGet) - cityPost(sm, "/order/{name}/enable", (*Server).humaHandleOrderEnable) - cityPost(sm, "/order/{name}/disable", (*Server).humaHandleOrderDisable) + cityGet(sm, "/orders", (*Server).humaHandleOrderList, errorStatuses(http.StatusNotFound)) + cityGet(sm, "/orders/check", (*Server).humaHandleOrderCheck, errorStatuses(http.StatusNotFound)) + cityGet(sm, "/orders/history", (*Server).humaHandleOrderHistory, errorStatuses(http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable)) + cityGet(sm, "/order/history/{bead_id}", (*Server).humaHandleOrderHistoryDetail, errorStatuses(http.StatusNotFound, http.StatusServiceUnavailable)) + cityGet(sm, "/order/{name}", (*Server).humaHandleOrderGet, errorStatuses(http.StatusNotFound, http.StatusConflict)) + cityPost(sm, "/order/{name}/enable", (*Server).humaHandleOrderEnable, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusNotImplemented)) + cityPost(sm, "/order/{name}/disable", (*Server).humaHandleOrderDisable, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusNotImplemented)) // Typed operator path to fire a trigger="webhook" order directly with typed // params. Inherits write-auth/CSRF/read-only from cityPost (write-auth IS the // auth here — no signature). Reuses the E6 sink + E0.5 dispatcher seam. cityPost(sm, "/order/{name}/run", (*Server).humaHandleOrderRun, func(op *huma.Operation) { op.DefaultStatus = http.StatusAccepted - }) - cityGet(sm, "/orders/feed", (*Server).humaHandleOrdersFeed) + }, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusServiceUnavailable)) + cityGet(sm, "/orders/feed", (*Server).humaHandleOrdersFeed, errorStatuses(http.StatusBadRequest, http.StatusNotFound)) // Formulas. - cityGet(sm, "/formulas", (*Server).humaHandleFormulaList) - cityGet(sm, "/formulas/{name}/runs", (*Server).humaHandleFormulaRuns) - cityGet(sm, "/formulas/{name}/source", (*Server).humaHandleFormulaSource) - cityGet(sm, "/formulas/{name}", (*Server).humaHandleFormulaDetail) - cityGet(sm, "/formula/{name}", (*Server).humaHandleFormulaDetail) - cityPost(sm, "/formulas/{name}/preview", (*Server).humaHandleFormulaPreview) - cityPost(sm, "/formulas/{name}/validate", (*Server).humaHandleFormulaValidate, withMaxFormulaBody) - cityPut(sm, "/formulas/{name}", (*Server).humaHandleFormulaUpsert, withMaxFormulaBody) - cityDelete(sm, "/formulas/{name}", (*Server).humaHandleFormulaDelete) - cityGet(sm, "/formulas/feed", (*Server).humaHandleFormulaFeed) + cityGet(sm, "/formulas", (*Server).humaHandleFormulaList, errorStatuses(http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable)) + cityGet(sm, "/formulas/{name}/runs", (*Server).humaHandleFormulaRuns, errorStatuses(http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable)) + cityGet(sm, "/formulas/{name}/source", (*Server).humaHandleFormulaSource, errorStatuses(http.StatusBadRequest, http.StatusNotFound, http.StatusNotImplemented)) + cityGet(sm, "/formulas/{name}", (*Server).humaHandleFormulaDetail, errorStatuses(http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable)) + cityGet(sm, "/formula/{name}", (*Server).humaHandleFormulaDetail, errorStatuses(http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable)) + cityPost(sm, "/formulas/{name}/preview", (*Server).humaHandleFormulaPreview, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusServiceUnavailable)) + cityPost(sm, "/formulas/{name}/validate", (*Server).humaHandleFormulaValidate, withMaxFormulaBody, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusRequestEntityTooLarge)) + cityPut(sm, "/formulas/{name}", (*Server).humaHandleFormulaUpsert, withMaxFormulaBody, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusRequestEntityTooLarge, http.StatusNotImplemented)) + cityDelete(sm, "/formulas/{name}", (*Server).humaHandleFormulaDelete, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented)) + cityGet(sm, "/formulas/feed", (*Server).humaHandleFormulaFeed, errorStatuses(http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable)) // Backwards-compatible workflow aliases. - cityGet(sm, "/workflow/{workflow_id}", (*Server).humaHandleWorkflowGet) - cityDelete(sm, "/workflow/{workflow_id}", (*Server).humaHandleWorkflowDelete) + cityGet(sm, "/workflow/{workflow_id}", (*Server).humaHandleWorkflowGet, errorStatuses(http.StatusBadRequest, http.StatusNotFound)) + cityDelete(sm, "/workflow/{workflow_id}", (*Server).humaHandleWorkflowDelete, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound)) // Packs. - cityGet(sm, "/packs", (*Server).humaHandlePackList) + cityGet(sm, "/packs", (*Server).humaHandlePackList, errorStatuses(http.StatusBadRequest, http.StatusNotFound)) cityRegister(sm, huma.Operation{ OperationID: "add-pack", Method: http.MethodPost, @@ -261,14 +277,16 @@ func (sm *SupervisorMux) registerCityRoutes() { Summary: "Add a pack", Description: "Imports a pack into the city by source (a remote git URL or registry ref), resolving + installing it so its templates compose into the city.", DefaultStatus: http.StatusCreated, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusBadGateway}, }, (*Server).humaHandlePackAdd) - cityDelete(sm, "/packs/{name}", (*Server).humaHandlePackRemove) + cityDelete(sm, "/packs/{name}", (*Server).humaHandlePackRemove, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound)) - // Sling. - cityPost(sm, "/sling", (*Server).humaHandleSling) + // Sling. Part of the P12 error-contract pilot (see Beads above); a mutation, + // so it also declares 403 for the CSRF/read-only middleware. + cityPost(sm, "/sling", (*Server).humaHandleSling, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict)) // Maintenance (Dolt store gc + snapshot). - cityGet(sm, "/maintenance/status", (*Server).humaHandleMaintenanceStatus) + cityGet(sm, "/maintenance/status", (*Server).humaHandleMaintenanceStatus, errorStatuses(http.StatusNotFound, http.StatusServiceUnavailable)) cityRegister(sm, huma.Operation{ OperationID: "trigger-maintenance-dolt-gc", Method: http.MethodPost, @@ -276,12 +294,13 @@ func (sm *SupervisorMux) registerCityRoutes() { Summary: "Trigger a Dolt store maintenance run", Description: "Trigger a one-off maintenance cycle (dolt backup + CALL DOLT_GC + smoke test). Default async (202); ?wait=true blocks until completion (200). Returns 409 when a run is already in flight.", DefaultStatus: http.StatusAccepted, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable}, }, (*Server).humaHandleMaintenanceTriggerDoltGC) // Services (workspace services). - cityGet(sm, "/services", (*Server).humaHandleServiceList) - cityGet(sm, "/service/{name}", (*Server).humaHandleServiceGet) - cityPost(sm, "/service/{name}/restart", (*Server).humaHandleServiceRestart) + cityGet(sm, "/services", (*Server).humaHandleServiceList, errorStatuses(http.StatusNotFound)) + cityGet(sm, "/service/{name}", (*Server).humaHandleServiceGet, errorStatuses(http.StatusNotFound)) + cityPost(sm, "/service/{name}/restart", (*Server).humaHandleServiceRestart, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound)) // Sessions (non-stream — stream is the SSE registration below). cityRegister(sm, huma.Operation{ @@ -290,20 +309,22 @@ func (sm *SupervisorMux) registerCityRoutes() { Path: "/sessions", Summary: "Create a session", DefaultStatus: http.StatusAccepted, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusServiceUnavailable}, }, (*Server).humaHandleSessionCreate) - cityGet(sm, "/sessions", (*Server).humaHandleSessionList) - cityGet(sm, "/session/{id}", (*Server).humaHandleSessionGet) - cityGet(sm, "/session/{id}/transcript", (*Server).humaHandleSessionTranscript) - cityGet(sm, "/session/{id}/pending", (*Server).humaHandleSessionPending) - cityGet(sm, "/pending", (*Server).humaHandleCityPending) - cityPatch(sm, "/session/{id}", (*Server).humaHandleSessionPatch) - cityPost(sm, "/session/{id}/permission-mode", (*Server).humaHandleSessionPermissionMode) + cityGet(sm, "/sessions", (*Server).humaHandleSessionList, errorStatuses(http.StatusNotFound, http.StatusServiceUnavailable)) + cityGet(sm, "/session/{id}", (*Server).humaHandleSessionGet, errorStatuses(http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable)) + cityGet(sm, "/session/{id}/transcript", (*Server).humaHandleSessionTranscript, errorStatuses(http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable)) + cityGet(sm, "/session/{id}/pending", (*Server).humaHandleSessionPending, errorStatuses(http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable)) + cityGet(sm, "/pending", (*Server).humaHandleCityPending, errorStatuses(http.StatusNotFound, http.StatusServiceUnavailable)) + cityPatch(sm, "/session/{id}", (*Server).humaHandleSessionPatch, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable)) + cityPost(sm, "/session/{id}/permission-mode", (*Server).humaHandleSessionPermissionMode, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusNotImplemented, http.StatusServiceUnavailable)) cityRegister(sm, huma.Operation{ OperationID: "submit-session", Method: http.MethodPost, Path: "/session/{id}/submit", Summary: "Submit a message to a session", DefaultStatus: http.StatusAccepted, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusServiceUnavailable}, }, (*Server).humaHandleSessionSubmit) cityRegister(sm, huma.Operation{ OperationID: "send-session-message", @@ -311,22 +332,24 @@ func (sm *SupervisorMux) registerCityRoutes() { Path: "/session/{id}/messages", Summary: "Send a message to a session", DefaultStatus: http.StatusAccepted, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusServiceUnavailable}, }, (*Server).humaHandleSessionMessage) - cityPost(sm, "/session/{id}/stop", (*Server).humaHandleSessionStop) - cityPost(sm, "/session/{id}/kill", (*Server).humaHandleSessionKill) + cityPost(sm, "/session/{id}/stop", (*Server).humaHandleSessionStop, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable)) + cityPost(sm, "/session/{id}/kill", (*Server).humaHandleSessionKill, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable)) cityRegister(sm, huma.Operation{ OperationID: "respond-session", Method: http.MethodPost, Path: "/session/{id}/respond", Summary: "Respond to a pending interaction", DefaultStatus: http.StatusAccepted, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusNotImplemented, http.StatusServiceUnavailable}, }, (*Server).humaHandleSessionRespond) - cityPost(sm, "/session/{id}/suspend", (*Server).humaHandleSessionSuspend) - cityPost(sm, "/session/{id}/close", (*Server).humaHandleSessionClose) - cityPost(sm, "/session/{id}/wake", (*Server).humaHandleSessionWake) - cityPost(sm, "/session/{id}/rename", (*Server).humaHandleSessionRename) - cityGet(sm, "/session/{id}/agents", (*Server).humaHandleSessionAgentList) - cityGet(sm, "/session/{id}/agents/{agentId}", (*Server).humaHandleSessionAgentGet) + cityPost(sm, "/session/{id}/suspend", (*Server).humaHandleSessionSuspend, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable)) + cityPost(sm, "/session/{id}/close", (*Server).humaHandleSessionClose, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable)) + cityPost(sm, "/session/{id}/wake", (*Server).humaHandleSessionWake, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable)) + cityPost(sm, "/session/{id}/rename", (*Server).humaHandleSessionRename, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable)) + cityGet(sm, "/session/{id}/agents", (*Server).humaHandleSessionAgentList, errorStatuses(http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable)) + cityGet(sm, "/session/{id}/agents/{agentId}", (*Server).humaHandleSessionAgentGet, errorStatuses(http.StatusBadRequest, http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable)) // Session SSE stream. registerSSE(sm.humaAPI, huma.Operation{ @@ -362,30 +385,32 @@ func (sm *SupervisorMux) registerCityRoutes() { sseCityStream(sm, (*Server).streamEvents)) // ExtMsg. - cityPost(sm, "/extmsg/inbound", (*Server).humaHandleExtMsgInbound) - cityPost(sm, "/extmsg/outbound", (*Server).humaHandleExtMsgOutbound) - cityGet(sm, "/extmsg/bindings", (*Server).humaHandleExtMsgBindingList) - cityPost(sm, "/extmsg/bind", (*Server).humaHandleExtMsgBind) - cityPost(sm, "/extmsg/unbind", (*Server).humaHandleExtMsgUnbind) - cityGet(sm, "/extmsg/groups", (*Server).humaHandleExtMsgGroupLookup) + cityPost(sm, "/extmsg/inbound", (*Server).humaHandleExtMsgInbound, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusServiceUnavailable)) + cityPost(sm, "/extmsg/outbound", (*Server).humaHandleExtMsgOutbound, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusServiceUnavailable)) + cityGet(sm, "/extmsg/bindings", (*Server).humaHandleExtMsgBindingList, errorStatuses(http.StatusBadRequest, http.StatusNotFound, http.StatusServiceUnavailable)) + cityPost(sm, "/extmsg/bind", (*Server).humaHandleExtMsgBind, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusConflict, http.StatusServiceUnavailable)) + cityPost(sm, "/extmsg/unbind", (*Server).humaHandleExtMsgUnbind, errorStatuses(http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusServiceUnavailable)) + cityGet(sm, "/extmsg/groups", (*Server).humaHandleExtMsgGroupLookup, errorStatuses(http.StatusNotFound, http.StatusServiceUnavailable)) cityRegister(sm, huma.Operation{ OperationID: "ensure-extmsg-group", Method: http.MethodPost, Path: "/extmsg/groups", Summary: "Ensure an external messaging group exists", DefaultStatus: http.StatusCreated, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusServiceUnavailable}, }, (*Server).humaHandleExtMsgGroupEnsure) - cityPost(sm, "/extmsg/participants", (*Server).humaHandleExtMsgParticipantUpsert) - cityDelete(sm, "/extmsg/participants", (*Server).humaHandleExtMsgParticipantRemove) - cityGet(sm, "/extmsg/transcript", (*Server).humaHandleExtMsgTranscriptList) - cityPost(sm, "/extmsg/transcript/ack", (*Server).humaHandleExtMsgTranscriptAck) - cityGet(sm, "/extmsg/adapters", (*Server).humaHandleExtMsgAdapterList) + cityPost(sm, "/extmsg/participants", (*Server).humaHandleExtMsgParticipantUpsert, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusServiceUnavailable)) + cityDelete(sm, "/extmsg/participants", (*Server).humaHandleExtMsgParticipantRemove, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusServiceUnavailable)) + cityGet(sm, "/extmsg/transcript", (*Server).humaHandleExtMsgTranscriptList, errorStatuses(http.StatusNotFound, http.StatusServiceUnavailable)) + cityPost(sm, "/extmsg/transcript/ack", (*Server).humaHandleExtMsgTranscriptAck, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusServiceUnavailable)) + cityGet(sm, "/extmsg/adapters", (*Server).humaHandleExtMsgAdapterList, errorStatuses(http.StatusNotFound, http.StatusServiceUnavailable)) cityRegister(sm, huma.Operation{ OperationID: "register-extmsg-adapter", Method: http.MethodPost, Path: "/extmsg/adapters", Summary: "Register an external messaging adapter", DefaultStatus: http.StatusCreated, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusServiceUnavailable}, }, (*Server).humaHandleExtMsgAdapterRegister) - cityDelete(sm, "/extmsg/adapters", (*Server).humaHandleExtMsgAdapterUnregister) + cityDelete(sm, "/extmsg/adapters", (*Server).humaHandleExtMsgAdapterUnregister, errorStatuses(http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusServiceUnavailable)) } From c2f5a325294e46a1adde64a0a0c76fa9fecaf7a9 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Fri, 10 Jul 2026 00:37:39 +0000 Subject: [PATCH 042/225] feat(rollout): boot-latch rollout gates in the composition root + gc doctor section (PR-1c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire internal/rollout into the controller composition root and surface the resolved gates in `gc doctor`. PR-1c is still INERT — nothing production consumes the latch yet (S2+ does) — so this is zero behavior change, but it establishes the ONE place the process resolves its gate values and the operator surface for seeing them. Composition root: - controllerState boot-latches rollout.Flags once in newControllerState and never re-resolves it. The beads.conditional_writes gate is deliberately NOT hot-reloaded: a legacy writer racing a CAS writer inside one process is the corruption the gate exists to prevent, so a divergent on-disk change waits for a restart. A resolve error (an out-of-enum typo — config.Parse does NOT enum-validate; internal/rollout is the enforcement point) is warn-and-continue with the zero Flags (legacy paths), never a hard failure. - noteRolloutDrift, hooked into BOTH reload seams (update / updateConfigAndProviderOnly), level-compares the reloaded config against the latch and records a NoticePendingRestart on divergence. Three level-triggered states — in-sync / drift / invalid-on-disk — each logging one stderr line per transition (not per reload). The invalid state is explicit: it records that a restart would fall back to legacy, so a previously logged "restart to apply X" never stands as a lie. - internal/api gains an optional RolloutFlagsProvider (modeled on RawConfigProvider / WebhookDispatchProvider so the test fakes need not grow it); Server latches bootFlags once — from the provider when present, else a Resolve-from-Config fallback for provider-less States. gc doctor: - A "Rollout gates" section renders one advisory line per registered rollout.Specs() gate (value + origin + category/owner/expires + any notices). REPORT-ONLY: always SeverityAdvisory, never StatusError, so a gate line can never gate the doctor exit code. The capability/fail-closed verdict is deferred to S3. The rendered snapshot is resolved from on-disk config + the doctor process's own env and is explicitly labelled as such, since a running controller latched its value from its own boot env. Supporting: - Export rollout.KeyBeadsConditionalWrites so composition-root code references the gate Key directly instead of matching it back out of the registry by a coincidental axis. - Flags.ValueOf(key): a render-only canonical-string accessor for doctor/status (production reads use the typed accessors). Tests prove the wiring, not just the helpers: a seam test drives the real update()/updateConfigAndProviderOnly() paths (deleting a noteRolloutDrift call or re-latching mid-reload fails it); the drift test asserts raw-spelling notices, off→auto value changes, invalid-on-disk warn+record, no re-latch, and one-line-per-transition logging via an injected log sink; the doctor tests assert exact value+origin, per-gate FlagKey notice filtering, and advisory-only severity. Verified against a Fable adversarial red-team pass (7 confirmed findings folded in; the env-var hermeticity finding was refuted — testenv scrubs GC_BEADS_CONDITIONAL_WRITES). Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/gc/api_state.go | 125 ++++++++++ cmd/gc/api_state_rollout_test.go | 165 +++++++++++++ cmd/gc/cmd_doctor.go | 22 ++ cmd/gc/doctor_rollout_gates.go | 87 +++++++ cmd/gc/doctor_rollout_gates_test.go | 157 ++++++++++++ cmd/gc/testdata/doctor_check_names.golden | 2 + engdocs/plans/feature-flags/EXECUTION-PLAN.md | 21 ++ engdocs/plans/feature-flags/PR1C-CODEMAP.md | 232 ++++++++++++++++++ internal/api/server.go | 18 ++ internal/api/server_rollout_test.go | 45 ++++ internal/api/state.go | 10 + .../rollout/flag_beads_conditional_writes.go | 10 +- internal/rollout/flags.go | 16 ++ internal/rollout/flags_test.go | 33 ++- 14 files changed, 940 insertions(+), 3 deletions(-) create mode 100644 cmd/gc/api_state_rollout_test.go create mode 100644 cmd/gc/doctor_rollout_gates.go create mode 100644 cmd/gc/doctor_rollout_gates_test.go create mode 100644 engdocs/plans/feature-flags/PR1C-CODEMAP.md create mode 100644 internal/api/server_rollout_test.go diff --git a/cmd/gc/api_state.go b/cmd/gc/api_state.go index d4823a2aa0..719f26b344 100644 --- a/cmd/gc/api_state.go +++ b/cmd/gc/api_state.go @@ -29,6 +29,7 @@ import ( "github.com/gastownhall/gascity/internal/orderdiscovery" "github.com/gastownhall/gascity/internal/orderdispatch" "github.com/gastownhall/gascity/internal/orders" + "github.com/gastownhall/gascity/internal/rollout" "github.com/gastownhall/gascity/internal/runtime" "github.com/gastownhall/gascity/internal/session" "github.com/gastownhall/gascity/internal/supervisor" @@ -87,6 +88,27 @@ type controllerState struct { // until the loop observes and applies the same or a newer on-disk config. configMutationPending atomic.Bool pendingConfigRev string + + // rolloutFlags is the boot-latched rollout-gate snapshot: written once in + // newControllerState, never reassigned (reads are lock-free by construction, + // like version/startedAt). The beads CAS gate is deliberately NOT re-resolved + // on reload — a legacy writer racing a CAS writer inside one process is the + // corruption it gates — so a divergent on-disk change surfaces as a + // pending-restart notice via noteRolloutDrift rather than flipping mid-run. + rolloutFlags rollout.Flags + // rolloutDriftMu guards rolloutDrift and rolloutDriftSig. + rolloutDriftMu sync.Mutex + // rolloutDrift holds a NoticePendingRestart when a reloaded config's beads + // gate diverges from the boot latch (or resolves invalid); nil when + // convergent (level-triggered: a later convergent reload clears it). + rolloutDrift *rollout.Notice + // rolloutDriftSig is the current drift signature, so noteRolloutDrift logs + // one stderr line per transition (into drift, into an invalid on-disk value, + // or back in sync) rather than one per reload. "" means in sync. + rolloutDriftSig string + // rolloutLogf, when non-nil, receives noteRolloutDrift's transition lines + // (tests capture it); nil falls back to os.Stderr via rolloutWarnf. + rolloutLogf func(format string, args ...any) } var controllerStateInitRigDirIfReady = initDirIfReady @@ -133,6 +155,14 @@ func newControllerState( beadEventStartSeq = seq } } + // Latch the rollout-gate snapshot ONCE from the boot config. A resolve error + // (nil cfg or an out-of-enum config value) is warn-and-continue: the zero + // Flags is degraded-safe (legacy paths), and this constructor returns no + // error — mirroring the best-effort city-store warn below. + rolloutFlags, rolloutErr := rollout.Resolve(cfg, rollout.ResolveOptions{}) + if rolloutErr != nil { + fmt.Fprintf(os.Stderr, "api: rollout gates: %v (using zero Flags; legacy paths)\n", rolloutErr) + } cs := &controllerState{ cfg: cfg, sp: sp, @@ -146,6 +176,7 @@ func newControllerState( startedAt: time.Now(), adapterReg: extmsg.NewAdapterRegistry(), beadEventStartSeq: beadEventStartSeq, + rolloutFlags: rolloutFlags, } cs.beadStores = cs.buildStores(cfg) // Capture the initial raw config snapshot so provenance reads before the @@ -618,6 +649,10 @@ func (cs *controllerState) update(cfg *config.City, sp runtime.Provider) { cs.updateMu.Lock() defer cs.updateMu.Unlock() + // The beads CAS gate is boot-latched: a reload that would change it only + // records a pending-restart notice, it does not flip the process mid-run. + cs.noteRolloutDrift(cfg) + // Build new stores outside the lock (may do file I/O / subprocess spawns). stores := cs.buildStores(cfg) storeSignature := storeMetadataSignature(cs.cityPath, cfg) @@ -772,6 +807,9 @@ func (cs *controllerState) updateConfigAndProviderOnly(cfg *config.City, sp runt cs.updateMu.Lock() defer cs.updateMu.Unlock() + // The beads CAS gate is boot-latched (see update). + cs.noteRolloutDrift(cfg) + // Recompute the usage sink so a changed [usage].provider takes effect even on // the store-reuse reload path. usageSink := usageSinkForCity(cfg, cs.cityPath) @@ -786,6 +824,86 @@ func (cs *controllerState) updateConfigAndProviderOnly(cfg *config.City, sp runt cs.mu.Unlock() } +// noteRolloutDrift level-compares the effective beads.conditional_writes gate a +// reloaded config WOULD resolve to against the boot latch and records the +// divergence for operators. It NEVER re-latches the gate: changing the CAS +// discipline mid-process is the corruption being gated, so the on-disk change +// waits for a restart. Three level-triggered states, each logging one line per +// transition (not per reload): +// - in sync: on-disk resolves to the boot value → drift cleared. +// - drift: on-disk resolves to a different valid value → NoticePendingRestart +// carrying the raw on-disk spelling; a restart would apply it. +// - invalid: on-disk fails to resolve (an out-of-enum typo — config.Parse does +// NOT enum-validate, internal/rollout does) → NoticePendingRestart noting the +// value is invalid, because a restart would warn and fall back to legacy +// (Off), so a previously recorded "restart to apply " must not stand. +func (cs *controllerState) noteRolloutDrift(next *config.City) { + boot := cs.rolloutFlags.BeadsConditionalWrites() + raw := next.Beads.ConditionalWrites + + var ( + notice *rollout.Notice + sig string // drift signature; "" means in sync + logLine string + ) + if nextFlags, err := rollout.Resolve(next, rollout.ResolveOptions{}); err != nil { + sig = "invalid:" + err.Error() + notice = &rollout.Notice{ + Kind: rollout.NoticePendingRestart, + FlagKey: rollout.KeyBeadsConditionalWrites, + ConfigValue: raw, + Message: fmt.Sprintf("beads.conditional_writes on disk (%q) is invalid (%v); the process stays latched to %q and a restart would fall back to legacy (off)", raw, err, boot), + } + logLine = fmt.Sprintf("api: rollout: reloaded beads.conditional_writes is invalid (%v); process stays latched to %q, on-disk value will NOT apply on restart\n", err, boot) + } else if onDisk := nextFlags.BeadsConditionalWrites(); onDisk != boot { + sig = "drift:" + string(onDisk) + notice = &rollout.Notice{ + Kind: rollout.NoticePendingRestart, + FlagKey: rollout.KeyBeadsConditionalWrites, + ConfigValue: raw, + Message: fmt.Sprintf("beads.conditional_writes on disk resolves to %q but the process latched %q at boot; restart to apply", onDisk, boot), + } + logLine = fmt.Sprintf("api: rollout: beads.conditional_writes on disk resolves to %q but the process is latched to %q; restart to apply\n", onDisk, boot) + } + + cs.rolloutDriftMu.Lock() + defer cs.rolloutDriftMu.Unlock() + prevSig := cs.rolloutDriftSig + cs.rolloutDrift = notice + cs.rolloutDriftSig = sig + if sig == prevSig { // no transition — stay quiet + return + } + if sig == "" { + cs.rolloutWarnf("api: rollout: beads.conditional_writes back in sync with the running process (%s)\n", boot) + return + } + cs.rolloutWarnf("%s", logLine) +} + +// rolloutWarnf routes noteRolloutDrift's transition lines to the injected sink +// (tests) or os.Stderr (production default). +func (cs *controllerState) rolloutWarnf(format string, args ...any) { + if cs.rolloutLogf != nil { + cs.rolloutLogf(format, args...) + return + } + fmt.Fprintf(os.Stderr, format, args...) +} + +// RolloutDriftNotices returns the pending-restart notices recorded by reloads +// (nil when the on-disk config agrees with the boot latch). The S4 status wire +// merges these with RolloutFlags().Notices(); in PR-1c the stderr transition +// line is the live operator surface. +func (cs *controllerState) RolloutDriftNotices() []rollout.Notice { + cs.rolloutDriftMu.Lock() + defer cs.rolloutDriftMu.Unlock() + if cs.rolloutDrift == nil { + return nil + } + return []rollout.Notice{*cs.rolloutDrift} +} + func (cs *controllerState) runtimeUpdateCanReuseCurrentStores(next *config.City) bool { cs.mu.RLock() current := cs.cfg @@ -1017,6 +1135,13 @@ func (cs *controllerState) Config() *config.City { return cs.cfg } +// RolloutFlags returns the boot-latched rollout-gate snapshot (api.RolloutFlagsProvider). +// Lock-free: rolloutFlags is written once at construction and never reassigned; +// reloads record drift via noteRolloutDrift rather than re-latching. +func (cs *controllerState) RolloutFlags() rollout.Flags { return cs.rolloutFlags } + +var _ api.RolloutFlagsProvider = (*controllerState)(nil) + // SessionProvider returns the current session provider. func (cs *controllerState) SessionProvider() runtime.Provider { cs.mu.RLock() diff --git a/cmd/gc/api_state_rollout_test.go b/cmd/gc/api_state_rollout_test.go new file mode 100644 index 0000000000..14116ea1fa --- /dev/null +++ b/cmd/gc/api_state_rollout_test.go @@ -0,0 +1,165 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/events" + "github.com/gastownhall/gascity/internal/rollout" + "github.com/gastownhall/gascity/internal/runtime" +) + +// countRolloutLogLines counts captured stderr transition lines containing sub. +func countRolloutLogLines(logs []string, sub string) int { + n := 0 + for _, l := range logs { + if strings.Contains(l, sub) { + n++ + } + } + return n +} + +// TestNewControllerStateLatchesRolloutFlags proves the boot config's rollout +// gates are resolved once and latched on the controllerState. +func TestNewControllerStateLatchesRolloutFlags(t *testing.T) { + stubManagedDoltStoreOpeners(t) + dir := t.TempDir() + toml := "[workspace]\nname = \"t\"\n\n[beads]\nconditional_writes = \"require\"\n" + if err := os.WriteFile(filepath.Join(dir, "city.toml"), []byte(toml), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := config.Parse([]byte(toml)) + if err != nil { + t.Fatal(err) + } + cs := newControllerState(context.Background(), cfg, nil, nil, "t", dir) + if got := cs.RolloutFlags().BeadsConditionalWrites(); got != rollout.Require { + t.Errorf("boot RolloutFlags beads = %q, want require", got) + } + if got := cs.RolloutFlags().OriginOf("beads.conditional_writes"); got != rollout.OriginConfig { + t.Errorf("boot origin = %q, want config", got) + } +} + +// TestControllerStateBootResolveErrorZeroFlags proves an out-of-enum config value +// warns and latches the zero (degraded-safe/legacy) Flags rather than aborting +// construction. +func TestControllerStateBootResolveErrorZeroFlags(t *testing.T) { + stubManagedDoltStoreOpeners(t) + dir := t.TempDir() + toml := "[beads]\nconditional_writes = \"requre\"\n" // typo → out-of-enum on Resolve + if err := os.WriteFile(filepath.Join(dir, "city.toml"), []byte(toml), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := config.Parse([]byte(toml)) + if err != nil { + t.Skipf("config.Parse rejects the typo before Resolve can (%v); enum validated earlier", err) + } + cs := newControllerState(context.Background(), cfg, nil, nil, "t", dir) + if got := cs.RolloutFlags().BeadsConditionalWrites(); got != rollout.ModeUnset { + t.Errorf("boot RolloutFlags after resolve error = %q, want ModeUnset (zero Flags)", got) + } +} + +// TestControllerStateRolloutDrift proves noteRolloutDrift is level-triggered: +// it records the raw on-disk spelling, updates when the drift target changes, +// warns+records (never silently drops) an invalid on-disk value, never +// re-latches the boot value, clears on convergence, and logs once per +// transition (not per reload). +func TestControllerStateRolloutDrift(t *testing.T) { + var logs []string + cs := &controllerState{ + rolloutFlags: rollout.ForTest(rollout.WithBeadsConditionalWrites(rollout.Require)), + rolloutLogf: func(f string, a ...any) { logs = append(logs, fmt.Sprintf(f, a...)) }, + } + if cs.RolloutDriftNotices() != nil { + t.Fatal("a fresh state should have no drift") + } + + // Two identical divergent reloads: drift recorded, logged once (per transition). + off := &config.City{Beads: config.BeadsConfig{ConditionalWrites: "off"}} + cs.noteRolloutDrift(off) + cs.noteRolloutDrift(off) + n := cs.RolloutDriftNotices() + if len(n) != 1 || n[0].Kind != rollout.NoticePendingRestart || n[0].FlagKey != rollout.KeyBeadsConditionalWrites { + t.Fatalf("divergent reload: want one NoticePendingRestart for the beads gate, got %+v", n) + } + if n[0].ConfigValue != "off" { + t.Errorf("notice ConfigValue = %q, want the raw on-disk spelling %q", n[0].ConfigValue, "off") + } + if got := cs.RolloutFlags().BeadsConditionalWrites(); got != rollout.Require { + t.Errorf("reload re-latched the gate: RolloutFlags = %q, want require (boot value)", got) + } + if got := countRolloutLogLines(logs, "restart to apply"); got != 1 { + t.Errorf("drift transition logged %d times across two identical reloads, want exactly 1", got) + } + + // Drift target changes off→auto: the notice value updates, not stale. + cs.noteRolloutDrift(&config.City{Beads: config.BeadsConfig{ConditionalWrites: "auto"}}) + if v := cs.RolloutDriftNotices()[0].ConfigValue; v != "auto" { + t.Errorf("after off→auto, notice ConfigValue = %q, want auto (not the stale off)", v) + } + + // Invalid on-disk value: warn once and replace the notice with an "invalid" + // one — never silently drop a live drift (a restart would fall back to legacy). + cs.noteRolloutDrift(&config.City{Beads: config.BeadsConfig{ConditionalWrites: "requre"}}) + inv := cs.RolloutDriftNotices() + if len(inv) != 1 || !strings.Contains(inv[0].Message, "invalid") || inv[0].ConfigValue != "requre" { + t.Fatalf("invalid reload: want one 'invalid' notice carrying the raw value, got %+v", inv) + } + if countRolloutLogLines(logs, "invalid") == 0 { + t.Errorf("invalid on-disk value produced no warn line; logs=%v", logs) + } + + // Convergent reload clears the drift and logs the back-in-sync transition once. + cs.noteRolloutDrift(&config.City{Beads: config.BeadsConfig{ConditionalWrites: "require"}}) + if cs.RolloutDriftNotices() != nil { + t.Errorf("convergent reload should clear drift, got %+v", cs.RolloutDriftNotices()) + } + if got := countRolloutLogLines(logs, "back in sync"); got != 1 { + t.Errorf("back-in-sync logged %d times, want 1; logs=%v", got, logs) + } +} + +// TestControllerStateRolloutDriftThroughReloadSeams proves the PRODUCTION reload +// seams — update() and updateConfigAndProviderOnly() — actually invoke +// noteRolloutDrift, and that a reload never re-latches the boot gate. Deleting +// either noteRolloutDrift call, or adding a re-latch inside a reload path, fails +// this test (the direct-call drift test above cannot see those seams). +func TestControllerStateRolloutDriftThroughReloadSeams(t *testing.T) { + t.Setenv("GC_BEADS", "file") + rig := t.TempDir() + cityOf := func(mode string) *config.City { + return &config.City{ + Workspace: config.Workspace{Name: "c"}, + Rigs: []config.Rig{{Name: "rig1", Path: rig}}, + Beads: config.BeadsConfig{ConditionalWrites: mode}, + } + } + + cs := newControllerState(context.Background(), cityOf("require"), runtime.NewFake(), events.NewFake(), "c", t.TempDir()) + if got := cs.RolloutFlags().BeadsConditionalWrites(); got != rollout.Require { + t.Fatalf("boot latch = %q, want require", got) + } + + // Reload via update(): on-disk drops to off → drift recorded, gate NOT re-latched. + cs.update(cityOf("off"), runtime.NewFake()) + if got := cs.RolloutFlags().BeadsConditionalWrites(); got != rollout.Require { + t.Errorf("update() re-latched the gate: %q, want require", got) + } + if n := cs.RolloutDriftNotices(); len(n) != 1 || n[0].Kind != rollout.NoticePendingRestart { + t.Fatalf("update() did not record drift through noteRolloutDrift: %+v", n) + } + + // Reload via updateConfigAndProviderOnly(): back to require → drift clears. + cs.updateConfigAndProviderOnly(cityOf("require"), runtime.NewFake()) + if n := cs.RolloutDriftNotices(); n != nil { + t.Errorf("convergent reload via updateConfigAndProviderOnly did not clear drift: %+v", n) + } +} diff --git a/cmd/gc/cmd_doctor.go b/cmd/gc/cmd_doctor.go index 7cb84441aa..81cbce8cfb 100644 --- a/cmd/gc/cmd_doctor.go +++ b/cmd/gc/cmd_doctor.go @@ -16,6 +16,7 @@ import ( "github.com/gastownhall/gascity/internal/fsys" "github.com/gastownhall/gascity/internal/orders" "github.com/gastownhall/gascity/internal/pathutil" + "github.com/gastownhall/gascity/internal/rollout" "github.com/gastownhall/gascity/internal/suspensionstate" "github.com/spf13/cobra" ) @@ -168,6 +169,10 @@ type buildDoctorChecksOpts struct { SupervisorRunning bool SkipCityDoltCheck bool SkipManagedDoltCheck bool + // RolloutFlags is the on-disk rollout-gate snapshot doctor renders; RolloutResolveErr + // is set when resolving it failed (an out-of-enum config value). + RolloutFlags rollout.Flags + RolloutResolveErr error } func doctorOrderFiringCurrentLastRunFunc(cityPath string, cfg *config.City, stderr io.Writer) doctor.OrderFiringCurrentLastRunFunc { @@ -217,6 +222,11 @@ func buildDoctorChecks(cityPath string, cfg *config.City, cfgErr error, opts bui } register(doctor.NewConfigValidCheck(cfg)) register(doctor.NewLegacySuspendedFieldCheck(cfg)) + // Rollout gates section: one advisory line per registered gate (value + + // origin + notices). Never blocks the exit code. + for _, c := range rolloutGateChecks(opts.RolloutFlags, opts.RolloutResolveErr) { + register(c) + } register(doctor.NewConfigRefsCheck(cfg, cityPath)) register(doctor.NewStaleLocalPackDirCheck(cfg.Packs, cfg.Imports, cfg.DefaultRigImports, cityPath, cfg.Rigs...)) register(doctor.NewPreStartScriptsCheck(cfg)) @@ -414,12 +424,24 @@ func doDoctor(fix, verbose, jsonOut, explainPostgresAuth bool, stdout, stderr io supervisorRunning := supervisorAliveHook() != 0 skipCityDoltCheck := gcDoltSkip() || (!scopeUsesManagedBdStoreContract(cityPath, cityPath) && !workspaceNeedsCityDoltCheck(cityPath, cfg)) skipManagedDoltCheck := managedDoltOpsCheckSkip(cityPath, cfg, cfgErr) + // Resolve the rollout-gate snapshot for the doctor section from the on-disk + // config plus THIS doctor process's env (Resolve's default LookupEnv); a + // running controller may have latched a different value from its own boot + // env, so the rendered lines are advisory, not the live latch (guarded: + // Resolve errors on a nil cfg). + var rolloutFlags rollout.Flags + var rolloutResolveErr error + if cfgErr == nil && cfg != nil { + rolloutFlags, rolloutResolveErr = rollout.Resolve(cfg, rollout.ResolveOptions{}) + } for _, check := range buildDoctorChecks(cityPath, cfg, cfgErr, buildDoctorChecksOpts{ Stderr: stderr, ControllerRunning: controllerRunning, SupervisorRunning: supervisorRunning, SkipCityDoltCheck: skipCityDoltCheck, SkipManagedDoltCheck: skipManagedDoltCheck, + RolloutFlags: rolloutFlags, + RolloutResolveErr: rolloutResolveErr, }) { d.Register(check) } diff --git a/cmd/gc/doctor_rollout_gates.go b/cmd/gc/doctor_rollout_gates.go new file mode 100644 index 0000000000..0d21b2a317 --- /dev/null +++ b/cmd/gc/doctor_rollout_gates.go @@ -0,0 +1,87 @@ +package main + +import ( + "fmt" + + "github.com/gastownhall/gascity/internal/doctor" + "github.com/gastownhall/gascity/internal/rollout" +) + +// rolloutGateCheck renders one registered rollout gate — its resolved value, +// origin, and any per-gate notices — for `gc doctor`. It is REPORT-ONLY: always +// SeverityAdvisory and never StatusError, so it never gates the exit code. The +// degraded/fail-closed capability verdict depends on the per-store capability +// probe (S3) and is deliberately not computed here; PR-1c is render-only. +// +// The snapshot is resolved fresh from the on-disk config PLUS this doctor +// process's own environment — so it can disagree with a running controller, +// which latched ITS value at ITS boot from ITS environment (a systemd unit's +// env need not match the operator's shell). doctor therefore cannot observe the +// live latch or its pending-restart drift; the controller's own logs carry +// those. The Run scope-qualifier Details line makes this explicit. Full runtime +// reconciliation lands with the S4 status wire. +type rolloutGateCheck struct { + spec rollout.Spec + flags rollout.Flags +} + +func (c rolloutGateCheck) Name() string { return "rollout:" + c.spec.Key } + +func (c rolloutGateCheck) Run(_ *doctor.CheckContext) *doctor.CheckResult { + res := &doctor.CheckResult{Name: c.Name(), Severity: doctor.SeverityAdvisory, Status: doctor.StatusOK} + res.Message = fmt.Sprintf("%s = %s (origin=%s)", c.spec.Key, c.flags.ValueOf(c.spec.Key), c.flags.OriginOf(c.spec.Key)) + + ctxLine := fmt.Sprintf("category=%s owner=%s", c.spec.Category, c.spec.Owner.GitHub) + if c.spec.Expires != "" { + ctxLine += " expires=" + c.spec.Expires + } + res.Details = append(res.Details, ctxLine) + res.Details = append(res.Details, "resolved from on-disk config + this process's env; a running controller latched its value at its own boot — see the controller logs for the live value") + + for _, n := range c.flags.Notices() { + if n.FlagKey == c.spec.Key { + res.Status = doctor.StatusWarning + res.Details = append(res.Details, n.Message) + } + } + return res +} + +func (c rolloutGateCheck) CanFix() bool { return false } +func (c rolloutGateCheck) Fix(_ *doctor.CheckContext) error { return nil } +func (c rolloutGateCheck) WarmupEligible() bool { return false } + +// rolloutResolveErrCheck is the single advisory check registered when doctor's +// rollout.Resolve failed (an out-of-enum config value; a nil cfg is excluded by +// the caller's cfg guard, so it never reaches here). +type rolloutResolveErrCheck struct{ err error } + +func (c rolloutResolveErrCheck) Name() string { return "rollout:resolve" } + +func (c rolloutResolveErrCheck) Run(_ *doctor.CheckContext) *doctor.CheckResult { + return &doctor.CheckResult{ + Name: c.Name(), + Severity: doctor.SeverityAdvisory, + Status: doctor.StatusWarning, + Message: fmt.Sprintf("rollout gates unresolved: %v", c.err), + } +} + +func (c rolloutResolveErrCheck) CanFix() bool { return false } +func (c rolloutResolveErrCheck) Fix(_ *doctor.CheckContext) error { return nil } +func (c rolloutResolveErrCheck) WarmupEligible() bool { return false } + +// rolloutGateChecks builds the doctor "Rollout gates" section: one advisory +// check per registered rollout.Specs() gate, or a single resolve-failure check +// when resolveErr is non-nil. Callers register these only when cfg loaded +// (cfgErr == nil && cfg != nil). +func rolloutGateChecks(flags rollout.Flags, resolveErr error) []doctor.Check { + if resolveErr != nil { + return []doctor.Check{rolloutResolveErrCheck{err: resolveErr}} + } + checks := make([]doctor.Check, 0, len(rollout.Specs())) + for _, s := range rollout.Specs() { + checks = append(checks, rolloutGateCheck{spec: s, flags: flags}) + } + return checks +} diff --git a/cmd/gc/doctor_rollout_gates_test.go b/cmd/gc/doctor_rollout_gates_test.go new file mode 100644 index 0000000000..794b578f1d --- /dev/null +++ b/cmd/gc/doctor_rollout_gates_test.go @@ -0,0 +1,157 @@ +package main + +import ( + "errors" + "strings" + "testing" + + "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/doctor" + "github.com/gastownhall/gascity/internal/rollout" +) + +// TestRolloutGateChecksAreAdvisoryPerGate proves the doctor section produces one +// report-only (SeverityAdvisory, never Error) check per registered gate, with a +// value+origin message — so it renders every gate and never gates the exit code. +func TestRolloutGateChecksAreAdvisoryPerGate(t *testing.T) { + flags := rollout.ForTest(rollout.WithBeadsConditionalWrites(rollout.Require), rollout.WithFormulaV2(false)) + checks := rolloutGateChecks(flags, nil) + if len(checks) != len(rollout.Specs()) { + t.Fatalf("got %d checks, want one per Spec (%d)", len(checks), len(rollout.Specs())) + } + ctx := &doctor.CheckContext{} + names := map[string]string{} + for _, c := range checks { + res := c.Run(ctx) + if res.Severity != doctor.SeverityAdvisory { + t.Errorf("%s: severity = %v, want SeverityAdvisory (must never block)", c.Name(), res.Severity) + } + if res.Status == doctor.StatusError { + t.Errorf("%s: status = error; PR-1c doctor is render-only", c.Name()) + } + if c.CanFix() || c.WarmupEligible() { + t.Errorf("%s: report-only check must not CanFix/WarmupEligible", c.Name()) + } + names[c.Name()] = res.Message + } + msg, ok := names["rollout:beads.conditional_writes"] + if !ok { + t.Fatalf("missing beads gate check; got %v", names) + } + // Assert the exact value+origin, not just that "origin=" appears — origin + // exists to reveal an env override, so a hardcoded literal must not satisfy it. + if !strings.Contains(msg, "= require (origin=config)") { + t.Errorf("message = %q, want %q", msg, "beads.conditional_writes = require (origin=config)") + } +} + +// TestRolloutGateCheckNoticeWarns proves a gate carrying a notice renders as a +// warning with the notice's actual message in Details, and — critically — that +// the FlagKey filter is per-gate: a notice belonging to the beads gate must NOT +// flip an unrelated gate's line to a warning. +func TestRolloutGateCheckNoticeWarns(t *testing.T) { + f, err := rollout.Resolve( + &config.City{Beads: config.BeadsConfig{ConditionalWrites: "require"}}, + rollout.ResolveOptions{LookupEnv: func(k string) (string, bool) { + if k == "GC_BEADS_CONDITIONAL_WRITES" { + return "auto", true // env overrides config → a beads-keyed notice + } + return "", false + }}, + ) + if err != nil { + t.Fatal(err) + } + var beadsSpec, fv2Spec rollout.Spec + for _, s := range rollout.Specs() { + switch s.Key { + case "beads.conditional_writes": + beadsSpec = s + case "daemon.formula_v2": + fv2Spec = s + } + } + + res := rolloutGateCheck{spec: beadsSpec, flags: f}.Run(&doctor.CheckContext{}) + if res.Status != doctor.StatusWarning || res.Severity != doctor.SeverityAdvisory { + t.Errorf("gate with a notice: status=%v severity=%v, want warning+advisory", res.Status, res.Severity) + } + // The notice's real message must reach Details, not a blank/placeholder. + hasNotice := false + for _, d := range res.Details { + if strings.Contains(d, "GC_BEADS_CONDITIONAL_WRITES") && strings.Contains(d, "overrides config") { + hasNotice = true + } + } + if !hasNotice { + t.Errorf("beads notice message missing from Details; got %v", res.Details) + } + + // Cross-gate: the same Flags carries only the beads notice, so the unrelated + // daemon.formula_v2 line must stay OK (kills a deleted-FlagKey-filter mutant). + if fv2Spec.Key == "" { + t.Fatal("daemon.formula_v2 spec not found") + } + other := rolloutGateCheck{spec: fv2Spec, flags: f}.Run(&doctor.CheckContext{}) + if other.Status != doctor.StatusOK { + t.Errorf("unrelated gate flipped to %v by a beads-keyed notice; want StatusOK. Details=%v", other.Status, other.Details) + } +} + +// TestRolloutGateChecksResolveError proves a resolve failure registers a single +// advisory warning rather than crashing or blocking. +func TestRolloutGateChecksResolveError(t *testing.T) { + checks := rolloutGateChecks(rollout.Flags{}, errors.New("beads.conditional_writes: invalid mode")) + if len(checks) != 1 { + t.Fatalf("resolve error: want 1 check, got %d", len(checks)) + } + res := checks[0].Run(&doctor.CheckContext{}) + if checks[0].Name() != "rollout:resolve" || res.Status != doctor.StatusWarning || res.Severity != doctor.SeverityAdvisory { + t.Errorf("resolve-error check = %s/%v/%v, want rollout:resolve/warning/advisory", checks[0].Name(), res.Status, res.Severity) + } +} + +// hasRolloutCheck reports whether any registered check is a rollout gate line. +func hasRolloutCheck(checks []doctor.Check, name string) bool { + for _, c := range checks { + if c.Name() == name { + return true + } + } + return false +} + +// TestBuildDoctorChecksRegistersRolloutGates proves the composition seam wires the +// rollout section into the doctor check set when the config loads cleanly. +func TestBuildDoctorChecksRegistersRolloutGates(t *testing.T) { + cfg := &config.City{Beads: config.BeadsConfig{ConditionalWrites: "require"}} + flags := rollout.ForTest(rollout.WithBeadsConditionalWrites(rollout.Require)) + checks := buildDoctorChecks(t.TempDir(), cfg, nil, buildDoctorChecksOpts{RolloutFlags: flags}) + if !hasRolloutCheck(checks, "rollout:beads.conditional_writes") { + t.Error("buildDoctorChecks did not register the beads rollout gate") + } +} + +// TestBuildDoctorChecksRegistersRolloutResolveError proves a boot resolve error +// surfaces as its single advisory check through the composition seam. +func TestBuildDoctorChecksRegistersRolloutResolveError(t *testing.T) { + checks := buildDoctorChecks(t.TempDir(), &config.City{}, nil, buildDoctorChecksOpts{RolloutResolveErr: errors.New("boom")}) + if !hasRolloutCheck(checks, "rollout:resolve") { + t.Error("buildDoctorChecks did not register the rollout resolve-error check") + } + if hasRolloutCheck(checks, "rollout:beads.conditional_writes") { + t.Error("resolve error should suppress the per-gate lines") + } +} + +// TestBuildDoctorChecksSkipsRolloutGatesWhenConfigFailed proves a config-load +// failure omits the rollout section entirely, so the parse error is not masked +// by a confusing gate line. +func TestBuildDoctorChecksSkipsRolloutGatesWhenConfigFailed(t *testing.T) { + checks := buildDoctorChecks(t.TempDir(), nil, errors.New("parse error"), buildDoctorChecksOpts{RolloutFlags: rollout.ForTest()}) + for _, c := range checks { + if strings.HasPrefix(c.Name(), "rollout:") { + t.Errorf("rollout gate %q registered despite config load failure", c.Name()) + } + } +} diff --git a/cmd/gc/testdata/doctor_check_names.golden b/cmd/gc/testdata/doctor_check_names.golden index 520a3c6b2d..4951896c06 100644 --- a/cmd/gc/testdata/doctor_check_names.golden +++ b/cmd/gc/testdata/doctor_check_names.golden @@ -19,6 +19,8 @@ dolt-topology dolt-drift config-valid legacy-suspended-field +rollout:beads.conditional_writes +rollout:daemon.formula_v2 config-refs stale-local-pack-dirs pre-start-scripts diff --git a/engdocs/plans/feature-flags/EXECUTION-PLAN.md b/engdocs/plans/feature-flags/EXECUTION-PLAN.md index 81910a492b..d877ba9113 100644 --- a/engdocs/plans/feature-flags/EXECUTION-PLAN.md +++ b/engdocs/plans/feature-flags/EXECUTION-PLAN.md @@ -913,3 +913,24 @@ _General-Auto: Stage 5 is the proof that internal/rollout is a general subsystem - The two-knob mapping (bd CLI tag vs Go module tag, steveyegge vs gastownhall paths) is recorded in S4-T0 but TestBDVersionPins still does not assert go.mod — the stretch lockstep assertion is unowned; silent CLI/library divergence remains possible until someone adds it. - Deferred radar: with scripts/rolloutradar cut to a doctor WARN + manual bead, lifecycle debt visibility depends on operators running doctor and on the FlipDueBy one-bump bound — a long gap between bd bumps stretches the window in which a pending flip can be forgotten without any nightly surfacing. - Ambiguity-tolerance ordering in C4 (findExistingAttach before the fence) is comment-and-test enforced, not type-enforced; a future refactor reordering it silently voids the false-loss tolerance — the seam comments and crash-retry test are the only tripwires. + +--- + +## Addendum (2026-07-09): close the raw-config→prompt seam + +**Context (from a design review of "flags that involve commands in prompts").** A rollout gate can +never inject a command into a prompt: `internal/prompt` cannot import `internal/rollout` (import-boundary +test), and the `PromptContext.Env` value-leak vector is AST-linted to forbid a `rollout.Flags` accessor +flowing into `Env`. The residual seam: the *raw* config field backing a gate (e.g. +`cfg.Beads.ConditionalWrites`) is a plain `internal/config` read, so a `cmd/gc` call site could technically +stuff it into `Env` and a template could branch a command on it — sidestepping `rollout` and re-creating +the forbidden "flag-gated agent behavior" pattern. + +**New task — PR-1a/1d (whichever lands the prompt-boundary AST lint): extend the `Env` lint to +rollout-gate ConfigPaths.** The set of guarded config paths is enumerable from the registry +(`rollout.Specs()[].ConfigPath`, e.g. `beads.conditional_writes`, `daemon.formula_v2`). The AST lint that +forbids `rollout.Flags` accessors → `Env` must ALSO forbid a read of any registered gate's ConfigPath +field (the Go selector for that toml path) → `Env`. Acceptance: a scratch call site doing +`ctx.Env["X"] = cfg.Beads.ConditionalWrites` fails the lint naming the file + the gate; a legit non-gate +config read into `Env` still passes. Rationale: rollout-gate values are Go-transport-only; agent-facing +command variation belongs in the pack's prompt template + a pinned bd version, not a gate. diff --git a/engdocs/plans/feature-flags/PR1C-CODEMAP.md b/engdocs/plans/feature-flags/PR1C-CODEMAP.md new file mode 100644 index 0000000000..5d071fe257 --- /dev/null +++ b/engdocs/plans/feature-flags/PR1C-CODEMAP.md @@ -0,0 +1,232 @@ +# PR-1c code map — composition-root wiring + gc doctor (Opus exploration) + +_Exact touchpoints for wiring rollout.Resolve at both roots (latched) + the doctor Rollout-gates section._ + +## state-interface: the api.State interface + concrete controllerState that both composition roots (cmd/gc applyFeatureFlag + +**Idiom:** Follow the optional-capability-provider idiom already in state.go (RawConfigProvider @236, WebhookDispatchProvider @246): declare `type RolloutFlagsProvider interface { RolloutFlags() rollout.Flags }` beside State in internal/api/state.go, implement it ONLY on controllerState (cmd/gc/api_state.go) backed by a boot-latched field resolved once in newControllerState (@119, near the cfg assignment @136), and consume it at the internal/api root with a type assertion + fallback: `flags := rollout.Resolve(state.Config(), opts); if p, ok := state.(RolloutFlagsProvider); ok { flags = p.RolloutFlags() }`. This avoids growing the base fake (fakeState) and matches the documented reason the two existing provider interfaces exist. If instead a core State method is preferred, the churn is still only 2 files (controllerState + fakeState) because all other fakes embed *fakeState — but the concrete-accessor-only variant is NOT viable since internal/api cannot import controllerState. + +**Churn:** Very low. Core-State-method route = 2 non-test-shape files (controllerState in cmd/gc/api_state.go + fakeState in internal/api/fake_state_test.go); all ~6 other fakes embed *fakeState and inherit for free. Optional-capability-interface route (idiomatic) = 1 production file (controllerState) + the interface decl in internal/api/state.go, zero fake edits. Either way the boot latch touches only newControllerState (api_state.go:119) and the two reload swap points (api_state.go:771 update path + loadCurrentConfigSnapshot @1808 divergence check). Consumption adds ~2 lines each at the two roots (cmd/gc feature_flags.go + internal/api server.go New/NewReadOnly), beside the legacy calls. No import cycle (rollout is a config-only leaf). + +- **State is an INTERFACE (not a concrete type). ~30 methods; Config() *config.City is the accessor both roots read.** — `internal/api/state.go:59 (interface); Config() at internal/api/state.go:61` + - shape: `type State interface { Config() *config.City; SessionProvider() runtime.Provider; BeadStore(rig string) beads.Store; ...; NudgesBeadStore() beads.NudgesStore; SessionsBeadStore() beads.SessionStore; GraphBeadStore() beads.GraphStore; ScopedStoreLike(ctx, existing) (beads.Store, error); Orders(); MaintenanceLoop() MaintenanceProvider }` + - note: Big interface. Any method ADDED to it forces every full implementer to grow it. Doc-comment on WebhookDispatchProvider (state.go:246) claims 'two production State implementers' — that is STALE for this worktree; see next entry. rollout.Flags return type is import-safe: rollout imports only config (leaf), never internal/api, so no cycle. +- **The ONLY full production State implementer: controllerState. (RWMutex-protected hot-reload struct.)** — `cmd/gc/api_state.go:44 (struct); Config() at cmd/gc/api_state.go:1014` + - shape: `type controllerState struct { mu sync.RWMutex; cfg *config.City; rawCfg *config.City; sp runtime.Provider; ...; updateMu sync.Mutex; configMutationPending atomic.Bool; pendingConfigRev string }` + - note: Verified sole prod impl via distinctive methods NudgesBeadStore/ScopedStoreLike/GraphBeadStore — only controllerState + fakeState implement them. serviceRuntime (cmd/gc/service_runtime.go:11) also has Config() but satisfies workspacesvc.RuntimeContext (8 methods, `var _ workspacesvc.RuntimeContext`), NOT api.State. This is the natural home for a boot-latched rolloutFlags field + a RolloutFlags() accessor. +- **Boot construction of controllerState (where rollout.Resolve would run once and latch).** — `constructor cmd/gc/api_state.go:119 (newControllerState); called at cmd/gc/controller.go:1335 (standalone controller) and cmd/gc/cmd_supervisor.go:2082 (supervisor multi-city)` + - shape: `func newControllerState(ctx context.Context, cfg *config.City, sp runtime.Provider, ep events.Provider, cityName, cityPath string) *controllerState` + - note: cfg is set at api_state.go:136 inside the &controllerState{...} literal. Add rolloutFlags := rollout.Resolve(cfg, opts) here and store on a new latched field; resolve exactly once. Both boot sites go through this one constructor, so a single edit latches both daemon shapes. +- **How controllerState reaches the internal/api composition root — state is available as `state State` at New/NewReadOnly, so RolloutFlags() can be read there via the interface (concrete type is NOT importable from internal/api).** — `internal/api/supervisor.go:406 New(state) / 408 NewReadOnly(state) → internal/api/server.go:196 New(state State) / 203 NewReadOnly → syncFeatureFlags(state.Config()) at server.go:229. Resolver wiring: cmd/gc/controller.go:1362 singleCityStateResolver{state: cs}` + - shape: `func New(state State) *Server { syncFeatureFlags(state.Config()); return newServer(state, false) } // syncFeatureFlags(cfg *config.City) at server.go:229` + - note: CRITICAL for churn decision: internal/api CANNOT import cmd/gc's controllerState (cmd/gc imports internal/api, not the reverse). So an accessor-on-concrete-type-only does NOT reach this root — internal/api must go through either a core State method or an optional-capability interface + type-assert on `state State`. +- **Fake inventory — churn cost is TINY. Exactly ONE base fake implements api.State; every other fake embeds it transitively.** — `base: internal/api/fake_state_test.go:38 (fakeState), Config() at :99. Embedders: fake_state_test.go:181 (fakeMutatorState *fakeState); handler_rigs_test.go:113 (sessionProviderOverrideState *fakeState); handler_mail_test.go:173 (multiProviderFakeState *fakeState); handler_sessions_test.go:440 (stateWithSessionProvider *fakeState); handler_agent_crud_test.go:46 (agentVisibilityFakeState *fakeMutatorState); handler_formula_write_test.go:15 (fakeFormulaState *fakeMutatorState)` + - shape: `fakeState is the sole type with its OWN Config()/NudgesBeadStore()/etc.; all others embed *fakeState (directly or via *fakeMutatorState) and inherit its methods.` + - note: Adding a method to the State interface = implement in exactly 2 files: controllerState (prod) + fakeState (base fake). All ~6 other fakes inherit for free by embedding. Non-api fakes (workspacesvc testRuntime) satisfy a different interface and are unaffected. +- **Established idiom for adding a capability WITHOUT touching the base fake: optional-capability provider interfaces beside State, reached by type-assertion.** — `internal/api/state.go:236 RawConfigProvider { RawConfig() *config.City }; internal/api/state.go:246 WebhookDispatchProvider { WebhookDispatcher() orderdispatch.Dispatcher }` + - shape: `type RawConfigProvider interface { RawConfig() *config.City } // consumed via `if p, ok := state.(RawConfigProvider); ok { ... }`` + - note: WebhookDispatchProvider's own doc-comment states the pattern was chosen so implementers 'and the test fakes are not all forced to grow a [method] they may not have.' Mirror this: type RolloutFlagsProvider interface { RolloutFlags() rollout.Flags } on controllerState only; beside-syncFeatureFlags type-asserts and falls back to rollout.Resolve(state.Config(), opts) when absent (tests). This ALSO satisfies the process-latch: provider returns the boot-latched value; the fallback resolves inline (fin +- **Reload/swap points that must NOT re-latch the beads gate (surface divergence as NoticePendingRestart instead).** — `cmd/gc/api_state.go:771 updateConfigAndProviderOnly (store-reuse swap); cmd/gc/api_state.go:1799 cs.update(nextCfg, sp) via refreshConfigSnapshot @1783; cmd/gc/api_state.go:1808 loadCurrentConfigSnapshot calls applyFeatureFlags(nextCfg); cmd/gc/controller.go:923 applyFeatureFlags(newCfg) on reload` + - shape: `func (cs *controllerState) updateConfigAndProviderOnly(cfg *config.City, sp runtime.Provider) // sets cs.cfg = cfg under cs.mu; leave a latched rolloutFlags field untouched` + - note: These swap cs.cfg on hot-reload. The LEGACY applyFeatureFlags/syncFeatureFlags re-apply formula/molecule flags freely (safe to hot-swap). The BEADS gate is the one that must stay latched to boot: on reload, compare rollout.Resolve(nextCfg) beads mode vs the boot-latched value and, on divergence, emit rollout NoticePendingRestart — do NOT flip the process-wide beads gate. PR-1c adds BESIDE these; do not modify the legacy calls until S5. +- **rollout public API shape to wire (PR-1b, committed) — grounds RolloutFlags() rollout.Flags return type.** — `internal/rollout/resolve.go:28 Resolve; internal/rollout/resolve.go:12 ResolveOptions; internal/rollout/flags.go:19 Flags; internal/rollout/notice.go:32 NoticePendingRestart` + - shape: `func Resolve(cfg *config.City, opts ResolveOptions) (Flags, error); type Flags struct{ beadsConditionalWrites resolved[Mode]; formulaV2 resolved[bool]; notices []Notice } (VALUE type); (f Flags) BeadsConditionalWrites() Mode; (f Flags) FormulaV2() bool; (f Flags) OriginOf(key string) Origin; (f Flags) Notices() []Notice; func Specs() []Spec` + - note: Flags is a VALUE struct — return by value, no nil concern. Zero-value Flags is valid (BeadsConditionalWrites()=zero Mode, FormulaV2()=false, Notices()=nil), which is exactly what a fake fallback returns when RolloutFlagsProvider is not implemented. + + GOTCHAS: + - Concrete-accessor-only on controllerState does NOT serve the internal/api root: cmd/gc imports internal/api (not the reverse), so internal/api can only reach RolloutFlags() through a core State method or an optional-capability interface + type assertion. Any 'add an accessor on the concrete type only' plan must still add an interface for the internal/api leg. + - The state.go WebhookDispatchProvider doc-comment says 'two production State implementers' — this is STALE in the reconciler worktree. Verified via distinctive methods (NudgesBeadStore/ScopedStoreLike/GraphBeadStore): controllerState is the ONLY full prod impl. serviceRuntime has Config() but implements workspacesvc.RuntimeContext, not api.State. + - Process-latch: State.Config() returns the HOT-RELOADED snapshot (swapped at api_state.go:771 and via cs.update). RolloutFlags() must NOT re-resolve from cs.cfg per call — it must return a field latched once at boot in newControllerState. Re-resolving would defeat the anti-corruption gate (a legacy writer racing a CAS writer in one process is exactly what's being gated). + - The legacy reload path re-applies feature flags at cmd/gc/api_state.go:1808 (loadCurrentConfigSnapshot → applyFeatureFlags) and cmd/gc/controller.go:923. formula/molecule flags are safe to hot-swap there; the BEADS gate is not. On reload, diff boot-latched beads mode vs freshly-loaded cfg and emit rollout.NoticePendingRestart — do not flip the gate. + - zsh: quote grep --include globs (`--include="*.go"`) or the command errors out and silently runs against nothing. Also there are unrelated nested worktrees under /data/projects/gascity/.worktrees/* (agent-provenance, citywrite-md, pack-crud) — filter them out; the target worktree is /data/projects/gascity/.claude/worktrees/reconciler. + - rollout.Flags carries an unexported []Notice slice; it is a value type with a valid zero value, so returning it by value from a fake fallback (empty Flags) is correct and safe — no pointer/nil handling needed. + +## boot-vs-reload: where config state is first constructed (boot) vs re-loaded (reload) at the two rollout composition root + +**Idiom:** Latch the beads gate on the LONG-LIVED per-city struct that survives reloads, resolve it ONCE at boot, and never re-resolve it. The cmd/gc root already has exactly one such struct: controllerState (api_state.go:44), constructed once in newControllerState (api_state.go:119) and thereafter only cfg-swapped by update/updateConfigAndProviderOnly (cs.cfg is reassigned at :650/:780; no other field on the struct is authoritative-latch material). Add an immutable `bootFlags rollout.Flags` field set from rollout.Resolve(cfg, rollout.ResolveOptions{}) inside newControllerState (beside cs.cfg=cfg at :137), and NEVER touch it in update()/updateConfigAndProviderOnly()/updateFromRuntime(). Both reload re-stamp sites (controller.go:923 tryReloadConfig, api_state.go:1808 loadCurrentConfigSnapshot) keep calling legacy applyFeatureFlags for formula_v2 (live re-stamp is fine there — S5 deletes it), but for the beads gate they must instead COMPARE the freshly-loaded cfg's beads mode against cs.bootFlags.BeadsConditionalWrites() and, on divergence, record a NoticePendingRestart into a SEPARATE mutable field (because rollout.Flags is an immutable value type — flags.go:19 — whose Notices() are frozen at Resolve time and cannot be appended to after boot). The doctor 'Rollout gates' section (S1-T9) is NOT process-latched: it is a short-lived invocation that calls rollout.Resolve on a fresh load (mirror doctor_provider_catalog.go:133-147) for display only. + +**Churn:** Wiring is additive and small: 1 new field + 1 Resolve call in newControllerState (api_state.go), 1 new cs-method for the drift check called from 2 reload sites (api_state.go:1808 and city_runtime.go:1999/reloadConfigTraced), 1 mutable pending-notice field on controllerState, and the internal/api New/NewReadOnly reading the latched value instead of re-resolving (server.go). Legacy applyFeatureFlags/syncFeatureFlags stay untouched until S5. ~3 files touched (api_state.go, city_runtime.go or controller.go, internal/api/server.go); the doctor section is a separate additive file. No fakes to update for the latch itself; rollout already ships rollout.ForTest for test construction. + +- **BOOT (cmd/gc root): first config load + legacy global stamp at process start** — `cmd/gc/cmd_start.go:667-673` + - shape: `cfg, prov, err := loadStartCityConfig(cityPath); ...; applyFeatureFlags(cfg) // :673` + - note: The earliest boot resolve point. This is where the BESIDE rollout.Resolve(cfg, opts) boot call could also be produced, but the durable LATCH must live on the long-lived struct (controllerState), not here — this frame returns. +- **BOOT (cmd/gc root): the long-lived per-city struct that survives every reload — the natural latch home** — `cmd/gc/api_state.go:44 (struct), :46 (cfg field)` + - shape: `type controllerState struct { mu sync.RWMutex; cfg *config.City; rawCfg *config.City; sp runtime.Provider; ... cityName, cityPath string; ... }` + - note: cfg (:46) is the only config field swapped on reload. Add `bootFlags rollout.Flags` here (immutable, set once) + a mutable pending-notice field (e.g. `pendingRestart []rollout.Notice` guarded by mu, or atomic.Pointer[rollout.Notice]) for the drift Notice. controllerState IS the api State, so both roots read one authoritative latch. +- **BOOT (cmd/gc root): the constructor — set bootFlags = rollout.Resolve(cfg,...) here, ONCE** — `cmd/gc/api_state.go:119 (func), :136-149 (literal), :137 (cs.cfg=cfg)` + - shape: `func newControllerState(ctx context.Context, cfg *config.City, sp runtime.Provider, ep events.Provider, cityName, cityPath string) *controllerState` + - note: cs := &controllerState{cfg: cfg, ...} at :136. Add `flags, err := rollout.Resolve(cfg, rollout.ResolveOptions{})` and stash cs.bootFlags = flags right here. Called from BOTH boot sites (controller.go:1335 and cmd_supervisor.go:2082). +- **BOOT (cmd/gc root): the two newControllerState call sites** — `cmd/gc/controller.go:1335 (standalone), cmd/gc/cmd_supervisor.go:2082 (supervisor)` + - shape: `cs := newControllerState(ctx, cfg, sp, eventProv, cityName, cityPath)` + - note: Both get the latch for free once it lives in newControllerState. controller.go:1307 (newCityRuntime, CityRuntimeParams.Cfg) and :1335 receive the SAME boot cfg. +- **BOOT (cmd/gc root): the reload driver struct — alternative latch home if cs-can-be-nil matters** — `cmd/gc/city_runtime.go:56 (struct), :68 (cfg), :93 (cs)` + - shape: `type CityRuntime struct { ... cfg *config.City (:68); ... cs *controllerState (:93, 'nil when controller-managed bead stores are unavailable') }` + - note: CityRuntime is always present (drives the watch reload loop); controllerState can be nil. If the standalone/no-controller path must also latch, put bootFlags on CityRuntime.cfg's owner instead of (or mirrored from) controllerState. +- **RELOAD path A (cmd/gc): file-watch reload driver — top of the reload** — `cmd/gc/city_runtime.go:1709 (reloadConfigTraced), :1726 (tryReloadConfig call), :1999 (updateFromRuntime call)` + - shape: `func (cr *CityRuntime) reloadConfigTraced(ctx context.Context, lastProviderName *string, cityRoot string, trace *sessionReconcilerTraceCycle, source reloadSource) reloadControlReply` + - note: After tryReloadConfig succeeds it holds result.Cfg; at :1999 it calls cr.cs.updateFromRuntime(nextCfg, nextSp, result.Revision). The beads-gate DRIFT CHECK for path A belongs here (has both result.Cfg and cr.cs.bootFlags) — cannot go inside tryReloadConfig (free function, no cs). +- **RELOAD path A (cmd/gc): the reload function that re-stamps the globals today** — `cmd/gc/controller.go:902 (func), :923 (applyFeatureFlags(newCfg))` + - shape: `func tryReloadConfig(tomlPath, lockedWorkspaceName, cityRoot string) (*reloadResult, error) // reloadResult{Cfg,Prov,Revision,Warnings} at :856` + - note: applyFeatureFlags at :923 RE-STAMPS formula_v2+graph globals on EVERY reload. Keep it for formula_v2; for the beads gate do NOT re-resolve here (no cs receiver). The reloaded config is returned as result.Cfg for the caller's drift check. +- **RELOAD path A (cmd/gc): propagates reloaded cfg into controllerState (cfg swap, latch untouched)** — `cmd/gc/api_state.go:743 (updateFromRuntime), :617 (update, cs.cfg=cfg at :650), :771 (updateConfigAndProviderOnly, cs.cfg=cfg at :780)` + - shape: `func (cs *controllerState) updateFromRuntime(cfg *config.City, sp runtime.Provider, revision string); func (cs *controllerState) update(cfg *config.City, sp runtime.Provider); func (cs *controllerState) updateConfigAndProviderOnly(cfg *config.City, sp runtime.Provider)` + - note: These reassign cs.cfg/rawCfg/sp/usageSink/stores ONLY. Confirmed they never touch a bootFlags field — so the latch survives all three. This is why controllerState is the correct latch home. +- **RELOAD path B (cmd/gc): API config-mutation refresh — the second re-stamp site** — `cmd/gc/api_state.go:1753 (mutateAndPoke), :1783 (refreshConfigSnapshot), :1803 (loadCurrentConfigSnapshot), :1808 (applyFeatureFlags(nextCfg))` + - shape: `func (cs *controllerState) refreshConfigSnapshot() (string, error); func (cs *controllerState) loadCurrentConfigSnapshot() (*config.City, string, error)` + - note: loadCurrentConfigSnapshot (:1803) IS a cs-method with cs + nextCfg in scope — the beads-gate DRIFT CHECK for path B slots in beside :1808 (compare nextCfg beads mode to cs.bootFlags.BeadsConditionalWrites()). Also called at api_state.go:912. Best: one shared cs.checkRolloutDrift(nextCfg) invoked from here AND path A. +- **BOOT (internal/api root): server construction — the only place syncFeatureFlags runs** — `internal/api/server.go:196 (New), :202 (NewReadOnly), :197/:203 (syncFeatureFlags(state.Config())), :229 (def)` + - shape: `func New(state State) *Server; func NewReadOnly(state State) *Server; func syncFeatureFlags(cfg *config.City) { enabled := cfg!=nil && cfg.Daemon.FormulaV2Enabled(); if formula.IsFormulaV2Enabled()!=enabled {...}; if molecule.IsGraphApplyEnabled()!=enabled {...} }` + - note: Compare-before-store (not unconditional). For PR-1c the rollout read here should pull the LATCHED flags off state (state is the controllerState), NOT call rollout.Resolve again — the process-latch is authored once at boot in newControllerState. +- **RELOAD (internal/api root): there is NONE per-reload — Server is cached, only rebuilt on State-pointer change (restart)** — `internal/api/supervisor.go:398 (getCityServer), :406 (New), :400 (cache hit when cached.state==state)` + - shape: `func (sm *SupervisorMux) getCityServer(name string, state State) *Server // returns cached srv when cached.state==state; else New(state)` + - note: In-place config reload keeps the SAME controllerState pointer (update() swaps cs.cfg, not the struct), so getCityServer returns the cached Server and syncFeatureFlags is SKIPPED on reload. The api root is already restart-latched; the beads gate just needs the boot-latched value threaded through state. +- **DOCTOR (S1-T9): the NON-latched, short-lived load pattern to mirror for the 'Rollout gates' section** — `cmd/gc/doctor_provider_catalog.go:133-147 (loadCityConfigAllowMissingProviderReferences), :146 (applyFeatureFlags)` + - shape: `func loadCityConfigAllowMissingProviderReferences(cityPath string) (*config.City, error) { ...config.LoadWithIncludesOptions(...); applyFeatureFlags(cfg); return cfg }` + - note: Doctor is a separate process invocation, NOT the controller — it should call rollout.Resolve on a fresh load for DISPLAY ONLY (render Flags.OriginOf + Flags.Notices, incl. any NoticePendingRestart the running controller would emit). It is not process-latched; do not add a latch here. +- **rollout API surface consumed by the latch + drift check (PR-1b)** — `internal/rollout/resolve.go:28 (Resolve), :12 (ResolveOptions); internal/rollout/flags.go:19 (Flags, immutable value), :40 (Notices); internal/rollout/flag_beads_conditional_writes.go:15 (BeadsConditionalWrites); internal/rollout/notice.go:32 (NoticePendingRestart), :38 (Notice)` + - shape: `func Resolve(cfg *config.City, opts ResolveOptions) (Flags, error); func (f Flags) BeadsConditionalWrites() Mode; func (f Flags) Notices() []Notice; const NoticePendingRestart NoticeKind = "pending_restart"` + - note: Resolve returns (Flags, error): error only on nil cfg or out-of-enum config value — boot must surface that. Flags is immutable: Notices() is frozen at Resolve time, so NoticePendingRestart cannot be appended to the latched Flags — record it in a separate mutable field on controllerState and merge at doctor/status render time. + + GOTCHAS: + - rollout.Flags is an IMMUTABLE VALUE TYPE (flags.go:19-23; Notices() copies out a frozen slice at flags.go:40-47). You CANNOT append a NoticePendingRestart to the boot-latched Flags on reload — it is a value copy. The divergence must be recorded in a SEPARATE mutable field on controllerState (e.g. a mutex-guarded []rollout.Notice or atomic.Pointer), which doctor/status reads ALONGSIDE cs.bootFlags. + - THERE ARE TWO DISTINCT RELOAD PATHS, both re-stamp the globals today, both must get the drift check: (A) the file-watch/runtime reload — reloadConfigTraced (city_runtime.go:1709) → tryReloadConfig (controller.go:902, applyFeatureFlags at :923) → cr.cs.updateFromRuntime (city_runtime.go:1999); and (B) the API config-mutation refresh — mutateAndPoke (api_state.go:1753) → refreshConfigSnapshot (api_s + - controllerState CAN BE NIL: CityRuntime.cs is documented 'nil when controller-managed bead stores are unavailable' (city_runtime.go:93), and the reload loop explicitly branches on `if cr.cs == nil` (city_runtime.go:2007). If the latch lives only on controllerState, a standalone city with no controller/API (bd unavailable) has no latch home. Either accept that (the beads CAS gate only matters when + - internal/api NEVER re-resolves on reload: syncFeatureFlags (server.go:229) runs only inside New/NewReadOnly (server.go:197/203), which getCityServer (supervisor.go:398-416) invokes ONLY when its per-city Server cache is empty OR the State pointer changed (city restart). In-place config reload mutates the SAME controllerState pointer (cs.cfg swap in update()), so getCityServer returns the CACHED se + - Boot ordering: the legacy global stamp happens EARLY at cmd_start.go:673 (applyFeatureFlags right after loadStartCityConfig at :667), BEFORE newCityRuntime (controller.go:1307) and newControllerState (controller.go:1335). The LATCH home (newControllerState) runs later, but sees the SAME boot cfg — so latching in newControllerState is correct. There are TWO boot construction sites for controllerSta + - formula_v2 vs beads asymmetry: the design latches ONLY the beads gate. formula_v2 keeps its live re-stamp on reload (applyFeatureFlags at controller.go:923 / api_state.go:1808 stays). Do NOT accidentally latch formula_v2 — Resolve returns both gates in one Flags value, so the drift check must select ONLY Flags.BeadsConditionalWrites() for the pending-restart comparison and let formula_v2 continue + +## gc doctor structure — check registration/run, result+severity types, exit-code contract, and the idiom for a non-blockin + +**Idiom:** Copy the v2DeprecationChecks section pattern (cmd/gc/doctor_v2_checks.go:26 + registration loop at cmd_doctor.go:201-203). Add a new file cmd/gc/doctor_rollout_gates.go: a `rolloutGateCheck` struct that closes over one rollout.Spec plus the boot rollout.Flags, modeled byte-for-byte on cmd/gc/doctor_fork_rate.go (self-contained CheckResult literal, Severity: doctor.SeverityAdvisory, CanFix=false, Fix=no-op, WarmupEligible=false, Name()=\"rollout:\"+spec.Key). Provide `rolloutGateChecks(flags rollout.Flags) []doctor.Check` that ranges rollout.Specs() and builds one check per Spec. Resolve rollout.Flags ONCE in doDoctor (next to controllerRunning at cmd_doctor.go:413-416) via rollout.Resolve(cfg, rollout.ResolveOptions{}), thread it through a new buildDoctorChecksOpts.RolloutFlags field, and register the section inside the cfgErr==nil && cfg!=nil block (cmd_doctor.go:212+). Each check renders: Message = \" = (origin=)\"; Details = per-gate Notices (Flags.Notices() filtered by FlagKey==spec.Key) plus Category/Owner/Expires context. Default Status=StatusOK; escalate to StatusWarning (keep SeverityAdvisory) only when a gate has a notice (esp. NoticePendingRestart / NoticeEnvOverridesConfig). NEVER emit StatusError/SeverityBlocking here — the degraded/fail-closed EffectiveStatus verdict depends on the S3 capability probe and is deferred; PR-1c is render-only (value + origin + notices). Add cmd/gc/doctor_rollout_gates_test.go with a ForTest Flags (rollout.ForTest / WithBeadsConditionalWrites / WithFormulaV2) asserting the rendered lines and that the section never increments BlockingFailed. + +**Churn:** Small, additive, no edits to legacy applyFeatureFlags/syncFeatureFlags. ~3 files touched for the doctor slice: 1 new file cmd/gc/doctor_rollout_gates.go (+ its _test.go, +its testenv_import if it were a new package — but it's package main, so no new testenv import needed) and ~10 lines edited in cmd/gc/cmd_doctor.go (add RolloutFlags to buildDoctorChecksOpts at :165, resolve Flags in doDoctor near :413, register the section near :212). Optional +1 tiny addition to internal/rollout (a render-only Flags.ValueOf(key) string accessor + one test) if the design wants the doctor loop to stay generic over Specs() instead of switching on Key. No OpenAPI/dashboard/event surface is touched. The separate composition-root wiring (S1-T8: applyFeatureFlags/syncFeatureFlags beside-the-legacy Resolve calls) is out of scope for this doctor map but shares the same rollout.Resolve entry point. + +- **Check interface every doctor check implements (5 methods incl. WarmupEligible)** — `internal/doctor/types.go:36-52` + - shape: `type Check interface { Name() string; Run(ctx *CheckContext) *CheckResult; CanFix() bool; Fix(ctx *CheckContext) error; WarmupEligible() bool }` + - note: A new self-contained check MUST implement all five. Rollout section is read-only: CanFix()=false, Fix()=nil no-op, WarmupEligible()=false (do not run in `gc start` warm-up). +- **CheckStatus enum — the three severity *levels* (OK/WARNING/ERROR)** — `internal/doctor/types.go:8-18` + - shape: `type CheckStatus int; const ( StatusOK CheckStatus = iota; StatusWarning; StatusError )` + - note: OK=0, Warning=1, Error=2. These are the display levels. Whether an Error gates is a *separate* axis (CheckSeverity). +- **CheckSeverity — the gating axis, independent of Status** — `internal/doctor/types.go:23-32` + - shape: `type CheckSeverity int; const ( SeverityBlocking CheckSeverity = iota; SeverityAdvisory )` + - note: Zero value = SeverityBlocking (every legacy error gates). Set Severity: SeverityAdvisory to make a failing check informational-only (does not affect exit code). Rollout section must be SeverityAdvisory so it never gates. +- **CheckResult — the finding struct all checks return** — `internal/doctor/types.go:80-104` + - shape: `type CheckResult struct { Name string; Status CheckStatus; Severity CheckSeverity; Message string; Details []string; FixHint string; FixError string; FixAttempted bool; Fixed bool }` + - note: Message is ALWAYS printed; Details print only under --verbose (doctor.go:137-141). One Run() returns exactly ONE result — to show N gates as N lines you register N checks. +- **CheckContext — shared per-run state a check receives** — `internal/doctor/types.go:55-69` + - shape: `type CheckContext struct { CityPath string; Verbose bool; Output io.Writer; ExplainPostgresAuth bool }` + - note: Checks that need cfg/Flags do NOT get them from ctx — they close over them at construction time (see AgentSessionsCheck, forkRateCheck). Thread resolved rollout.Flags in via the constructor. +- **Renderer optional interface (extra output after the summary line)** — `internal/doctor/types.go:71-78` + - shape: `type Renderer interface { RenderExtras(ctx *CheckContext, w io.Writer) }` + - note: How --explain-postgres-auth prints its table. A single rollout-gates check COULD implement this to always print a per-gate table; but the simpler one-check-per-Spec idiom is preferred (see idiom field). Renderer is only invoked on the streaming Run path (doctor.go:90-92), not on RunCollect/--json. +- **Doctor runner: Register + Run/RunCollect + the tally that defines BlockingFailed** — `internal/doctor/doctor.go:34-52,96-109` + - shape: `func (d *Doctor) Register(c Check); func (d *Doctor) Run(ctx *CheckContext, w io.Writer, fix bool) *Report; func (d *Doctor) RunCollect(ctx *CheckContext, fix bool) *Report` + - note: Tally (doctor.go:104-108): r.Failed++ on StatusError; r.BlockingFailed++ ONLY when result.Status==StatusError && result.Severity==SeverityBlocking. So a StatusWarning or an advisory StatusError never increments BlockingFailed. +- **Report struct (BlockingFailed is the gate signal)** — `internal/doctor/doctor.go:8-26` + - shape: `type Report struct { Passed, Warned, Failed, BlockingFailed, Fixed int; Results []*CheckResult }` + - note: Results holds every result in registration order; the --json path projects from it. +- **EXIT-CODE CONTRACT — the single decision point** — `cmd/gc/cmd_doctor.go:439-442` + - shape: `if report.BlockingFailed > 0 { return 1 }; return 0` + - note: Exit 1 iff BlockingFailed>0 (RunE maps a non-zero return to errExit, cmd_doctor.go:52-56). StatusWarning and SeverityAdvisory StatusError => exit 0. Rollout section, being advisory, CANNOT change the exit code. +- **buildDoctorChecks — THE composition point where every check is registered** — `cmd/gc/cmd_doctor.go:187-398` + - shape: `func buildDoctorChecks(cityPath string, cfg *config.City, cfgErr error, opts buildDoctorChecksOpts) []doctor.Check // uses a local register(c doctor.Check) closure (line 189)` + - note: Add the rollout section here, in the cfgErr==nil && cfg!=nil block (line 212+) so it only runs when config loaded cleanly. This is where boot Flags are available. +- **doDoctor — loads cfg, computes opts, registers, runs, returns exit code** — `cmd/gc/cmd_doctor.go:400-443` + - shape: `func doDoctor(fix, verbose, jsonOut, explainPostgresAuth bool, stdout, stderr io.Writer) int; cfg,cfgErr := loadCityConfig(cityPath, stderr) at line 409` + - note: THIS is where doctor gets resolved config (loadCityConfig, line 409). Resolve rollout.Flags here — rollout.Resolve(cfg, rollout.ResolveOptions{}) — once, next to controllerRunning/supervisorRunning (lines 413-416), and pass it through buildDoctorChecksOpts. `gc doctor` is its own process, so this IS its boot; there is no separate latched Flags to read cross-process. +- **buildDoctorChecksOpts — the injection vehicle for pre-computed boot state** — `cmd/gc/cmd_doctor.go:165-171` + - shape: `type buildDoctorChecksOpts struct { Stderr io.Writer; ControllerRunning, SupervisorRunning, SkipCityDoltCheck, SkipManagedDoltCheck bool }` + - note: Add a field here, e.g. `RolloutFlags rollout.Flags`, set in doDoctor, read in buildDoctorChecks. Mirrors how ControllerRunning is threaded. +- **Section idiom — a []doctor.Check helper registered via a loop** — `cmd/gc/cmd_doctor.go:201-203 (call) + cmd/gc/doctor_v2_checks.go:26-39 (helper)` + - shape: `for _, c := range v2DeprecationChecks() { register(c) } // func v2DeprecationChecks() []doctor.Check` + - note: EXACT pattern to copy for the Rollout gates section: a `rolloutGateChecks(flags rollout.Flags) []doctor.Check` returning one check per rollout.Specs() entry, registered by a range-loop. There is no literal 'section header' in doctor output — a section is just a set of checks sharing a Name prefix (e.g. "rollout:"), like the "rig::..." and "v2-..." families. +- **cmd/gc-level result constructors (use these, not the internal ErrorCheck)** — `cmd/gc/doctor_v2_checks.go:1246-1268` + - shape: `func okCheck(name, message string) *doctor.CheckResult; func warnCheck(name, message, hint string, details []string) *doctor.CheckResult; func errorCheck(name, message, hint string, details []string) *doctor.CheckResult` + - note: These build CheckResult with Status set (OK/Warning/Error) but DEFAULT Severity (SeverityBlocking). For the advisory rollout section, set res.Severity = doctor.SeverityAdvisory explicitly after constructing (or build the CheckResult literal directly, as forkRateCheck does). +- **Model check to copy — self-contained advisory cmd/gc check** — `cmd/gc/doctor_fork_rate.go:33-154` + - shape: `type forkRateCheck struct{...}; res := &doctor.CheckResult{Name: c.Name(), Severity: doctor.SeverityAdvisory} (line 97); Name/CanFix/Fix/WarmupEligible one-liners at 74-77` + - note: Best template: constructs its own CheckResult literal, pins Severity: SeverityAdvisory, WarmupEligible()=false, CanFix()=false. A rollout gate check should look almost identical, closing over one rollout.Spec + the boot rollout.Flags. +- **--json projection (severity crosses the wire)** — `cmd/gc/cmd_doctor.go:544-611` + - shape: `doctorJSONResult{ Name,Status,Severity,Message,FixHint,Details,... }; doctorSeverityString: SeverityAdvisory->"advisory", SeverityBlocking->"blocking" (578-586); doctorStatusString: ok/warning/error (566-576)` + - note: No extra wiring needed for the new section — RunCollect populates Report.Results and writeDoctorJSON projects Severity automatically. A rollout gate check's advisory status renders as {"status":"ok|warning","severity":"advisory"}. +- **rollout.Specs() — the registry the section iterates** — `internal/rollout/registry.go:54-66` + - shape: `func Specs() []Spec // deep-copied; Spec fields at spec.go:55-67: Key, Category, ConfigPath, EnvOverride, Owner{Bead,GitHub}, Expires, VersionAnchor, SelectsBetween, Default{Mode *Mode, Bool *bool}` + - note: Two live gates: beads.conditional_writes (InfraRollout, Mode, Default Off, Expires 2027-01-15) and daemon.formula_v2 (InfraMigration, bool, Default true, Expires 2026-12-31). Render per-gate: Key, resolved value, Origin, Category/Owner/Expires as context. +- **rollout.Resolve — how the doctor obtains boot Flags** — `internal/rollout/resolve.go:28` + - shape: `func Resolve(cfg *config.City, opts ResolveOptions) (Flags, error) // ResolveOptions{LookupEnv func(string)(string,bool)} zero value = os.LookupEnv` + - note: Call once in doDoctor with rollout.ResolveOptions{}. Non-nil error only on nil cfg or an out-of-enum CONFIG value; a bad env value is a Notice, not an error. On error, register a single StatusWarning+SeverityAdvisory 'rollout-gates unresolved' check rather than crashing doctor. +- **Flags accessors — value + origin + notices surface** — `internal/rollout/flags.go:28,40 + flag_beads_conditional_writes.go:15 + flag_daemon_formula_v2.go:10` + - shape: `func (f Flags) BeadsConditionalWrites() Mode; func (f Flags) FormulaV2() bool; func (f Flags) OriginOf(key string) Origin; func (f Flags) Notices() []Notice` + - note: OriginOf is documented 'for doctor/status rendering only' (flags.go:26). Notices() is process-wide; filter by Notice.FlagKey==spec.Key to attach notices to a gate. NOTE the value gap in gotchas: there is NO generic ValueOf(key). +- **Origin + Notice types the section renders** — `internal/rollout/notice.go:4-13,15-45` + - shape: `type Origin string { OriginBuiltin "builtin", OriginConfig "config", OriginEnv "env" }; type Notice struct { Kind NoticeKind; FlagKey, EnvVar, ConfigValue, EnvValue, Message string }` + - note: NoticeKind incl. NoticePendingRestart (notice.go:32, 'on-disk config diverged from boot-latched value — the reload wiring that emits it lands with this PR-1c wiring'). Render Notice.Message verbatim as a Details line; a NoticePendingRestart or NoticeEnvOverridesConfig is the natural trigger for StatusWarning (still SeverityAdvisory). + + GOTCHAS: + - VALUE-BY-KEY GAP: Flags exposes value only via the two TYPED accessors (BeadsConditionalWrites() Mode, FormulaV2() bool). The only key-generic accessors are OriginOf(key) Origin and Notices(). To render each Specs() gate generically you must either switch on spec.Key to call the typed accessor (brittle duplicate of the registry) OR add a render-only sibling `func (f Flags) ValueOf(key string) stri + - One Run() = one CheckResult = one printed line (Message). Details print ONLY under --verbose (doctor.go:137). So register ONE check PER Spec (like NewBinaryCheck per binary) to get a visible line per gate — do NOT cram all gates into a single check's Details, or they vanish without --verbose. + - Severity vs Status are ORTHOGONAL. Setting StatusError alone WOULD gate (Severity zero-value = SeverityBlocking). You must explicitly set Severity: doctor.SeverityAdvisory on every rollout result. The cmd/gc okCheck/warnCheck/errorCheck helpers do NOT set Severity, so they default to Blocking — either post-assign res.Severity=SeverityAdvisory or build the CheckResult literal directly (forkRateChec + - doDoctor is a fresh CLI process; its 'boot Flags' = rollout.Resolve(cfg) at doctor start. It cannot and should not reach into a separate running controller/api process's latched Flags. The process-latching + NoticePendingRestart concern belongs to the LONG-RUNNING roots (api syncFeatureFlags / controller reload), NOT to gc doctor. Doctor just resolves the current on-disk cfg and renders. + - Renderer.RenderExtras only fires on the streaming Run path (doctor.go:90-92); RunCollect/--json skips it. If you used a single Renderer-based check, the --json output would omit the per-gate table. One-check-per-Spec avoids this — the --json projection (cmd_doctor.go:597-609) picks up every registered check's result uniformly, including Severity. + - Gate the section on cfgErr==nil && cfg!=nil (register inside the block at cmd_doctor.go:212). rollout.Resolve returns an error on nil cfg; if you resolve outside that guard you'll either panic on nil or need a fallback. Also: Resolve's error path is reserved for a nil cfg or an out-of-enum config value — surface that as a single advisory 'rollout-gates unresolved' warning, don't abort doDoctor. + +## root-wiring-sites: exact insertion points to wire rollout.Resolve at the two composition roots (cmd/gc controllerState b + +**Idiom:** Add an immutable `bootFlags rollout.Flags` field to each root's boot-lifetime state and set it ONCE at the construction literal, mirroring an existing write-once boot value (controllerState.startedAt/version at api_state.go:146,145; Server.readOnly at server.go:55). Resolve production-side with `rollout.Resolve(cfg, rollout.ResolveOptions{})` (zero opts = os.LookupEnv), nil-guarding cfg because Resolve errors on nil. Expose it via a lock-free accessor (controllerState.BootFlags() beside Config() at api_state.go:1014; Server reads s.bootFlags directly). Leave the legacy applyFeatureFlags (feature_flags.go:12) / syncFeatureFlags (server.go:229) UNTOUCHED and add rollout beside them (deleted in S5). For the beads gate, PROCESS-LATCH: set at boot only, and on the reload paths (api_state.go:1808, controller.go:923) compare reloaded-config beads.conditional_writes against the latched bootFlags and emit NoticePendingRestart on divergence instead of re-resolving. For the doctor section, implement doctor.Check (types.go:36) as a report-only SeverityAdvisory check, register it in cmd_doctor.go buildDoctorChecks under the `cfgErr==nil && cfg!=nil` guard, and render one line per rollout.Specs() gate (Spec.Key + value + Flags.OriginOf) plus Flags.Notices() as Details. + +**Churn:** ~4-6 files, no fakes. cmd/gc/api_state.go (struct field + newControllerState literal set + BootFlags() accessor + reload-divergence compare on the beads gate at :1808/update), internal/api/server.go (struct field + newServer resolve at :209-216), new file cmd/gc/doctor_rollout_gates.go + one register(...) line in cmd/gc/cmd_doctor.go buildDoctorChecks, plus their _test.go. rollout.Resolve is a pure function whose only seam is ResolveOptions.LookupEnv, so tests inject a map-backed lookup — no provider/store fakes touched. Both cmd/gc and internal/api already import internal/config, and internal/rollout imports internal/config, so importing internal/rollout at both roots and in internal/doctor is cycle-free. + +- **Root #1 legacy setter (leave in place; add beside it)** — `cmd/gc/feature_flags.go:12` + - shape: `func applyFeatureFlags(cfg *config.City) { gw := cfg.Daemon.FormulaV2Enabled(); formula.SetFormulaV2Enabled(gw); molecule.SetGraphApplyEnabled(gw) }` + - note: 3-line global-stamp. PR-1c does NOT modify this. The rollout.Resolve add lands on the BOOT STATE (controllerState), not here. +- **cmd/gc BOOT STATE that doctor+downstream read — the durable process-lifetime state built at boot** — `cmd/gc/api_state.go:44 (struct), constructed at api_state.go:119 / literal api_state.go:136-149` + - shape: `type controllerState struct { mu sync.RWMutex; cfg *config.City; rawCfg *config.City; sp runtime.Provider; ...; version string (:66); startedAt time.Time (:67); ... } // func newControllerState(ctx, cfg *config.City, sp runtime.Provider, ep events.Provider, cityName, cityPath string) *controllerState` + - note: THIS is the answer to 'where does resolved Flags get stored so doctor+downstream can read it'. Add an immutable field e.g. `bootFlags rollout.Flags` to the struct (beside version/startedAt), and set it ONCE in the newControllerState literal (after `beadEventStartSeq: beadEventStartSeq,` at :148). startedAt/version are the precedent: boot-time-immutable scalars set only in this literal. +- **Resolve call shape at the cmd/gc boot literal** — `cmd/gc/api_state.go:136-149 (inside newControllerState)` + - shape: `flags, err := rollout.Resolve(cfg, rollout.ResolveOptions{}) // production passes zero opts → os.LookupEnv; store flags on cs.bootFlags. Resolve errors only on nil cfg / out-of-enum config typo — surface via os.Stderr like the existing city-store best-effort warn at :157.` + - note: newControllerState already receives cfg *config.City. rollout.Resolve is pure; the only seam is ResolveOptions.LookupEnv (nil=os.LookupEnv), so no fake wiring needed. +- **Boot-state accessor to add (mirror Config())** — `cmd/gc/api_state.go:1014 (Config), add BootFlags beside it` + - shape: `func (cs *controllerState) Config() *config.City { cs.mu.RLock(); defer cs.mu.RUnlock(); return cs.cfg } → add: func (cs *controllerState) BootFlags() rollout.Flags { return cs.bootFlags } (no lock needed: latched once, never reassigned)` + - note: Downstream consumers + a doctor/status read path call cs.BootFlags(). Keep it lock-free since bootFlags is write-once at construction. +- **cmd/gc boot construction call sites (where newControllerState actually runs at process boot)** — `cmd/gc/controller.go:1335 ; cmd/gc/cmd_supervisor.go:2082` + - shape: `controller.go:1335 cs := newControllerState(ctx, cfg, sp, eventProv, cityName, cityPath) // cmd_supervisor.go:2082 cs = newControllerState(cityCtx, cfg, sp, eventProv, cityName, path)` + - note: These are the two real boot entrypoints for the durable controller. `gc start` (cmd_start.go:667 load, :673 applyFeatureFlags) precedes and spawns the controller, but the LATCH lives on controllerState — set it inside newControllerState so both boot paths get it for free. +- **cmd/gc RELOAD paths that re-stamp today — must NOT re-resolve bootFlags (process-latch)** — `cmd/gc/api_state.go:1803-1811 (loadCurrentConfigSnapshot, applyFeatureFlags at :1808) ; cmd/gc/controller.go:902-923 (tryReloadConfig, applyFeatureFlags at :923) ; swap in cmd/gc/api_state.go:617 (update)` + - shape: `update() reassigns cs.cfg at :650 under cs.mu.Lock() but must LEAVE cs.bootFlags untouched. On reload, compare rollout.Resolve(newCfg).BeadsConditionalWrites() vs cs.bootFlags.BeadsConditionalWrites(); on divergence emit a NoticePendingRestart rather than re-latching.` + - note: The beads gate is the corruption being gated (legacy writer racing a CAS writer in one process), so it is process-latched: leave legacy applyFeatureFlags re-applying formula_v2 (hot-reloadable) as-is, and add the beads compare BESIDE it. update()/updateFromRuntime(:743)/updateConfigAndProviderOnly(:771) must never write bootFlags. +- **Root #2 legacy setter (leave in place; add beside it)** — `internal/api/server.go:229, called from New:197 / NewReadOnly:203` + - shape: `func syncFeatureFlags(cfg *config.City) { enabled := cfg != nil && cfg.Daemon.FormulaV2Enabled(); if formula.IsFormulaV2Enabled()!=enabled {formula.SetFormulaV2Enabled(enabled)}; if molecule.IsGraphApplyEnabled()!=enabled {molecule.SetGraphApplyEnabled(enabled)} }` + - note: Compare-before-store variant of applyFeatureFlags, nil-guarded. PR-1c leaves it; rollout.Resolve is added in newServer. +- **internal/api BOOT STATE — the Server, cached one-per-city for process lifetime** — `internal/api/server.go:52-129 (Server struct), single funnel newServer at server.go:207-224` + - shape: `type Server struct { state State (:53); readOnly bool (:55); ...; componentVersionsOnce sync.Once (:93); componentVersionsValue componentVersions (:94); ... } // func newServer(state State, readOnly bool) *Server { ... s := &Server{ state, mux, readOnly, idem, webhookDedup, webhookLimiter }; ... }` + - note: Add immutable field `bootFlags rollout.Flags` to Server and set it in the newServer literal (server.go:209-216). newServer is the SINGLE funnel — both New(:198) and NewReadOnly(:204) route through it, so resolve once here (guard nil first: `if cfg:=state.Config(); cfg!=nil { s.bootFlags,_ = rollout.Resolve(cfg, rollout.ResolveOptions{}) }`). Handlers read s.bootFlags. +- **Precedent to mirror on Server (resolve-once, immutable-for-process-lifetime)** — `internal/api/server.go:55 (readOnly bool) ; server.go:93-95 (componentVersionsOnce/Value/Probe)` + - shape: `readOnly = boot-time immutable scalar set only in newServer, read by handlers. componentVersions = 'Binary versions are immutable for the process lifetime, so resolved once on first read' (sync.Once).` + - note: bootFlags follows the readOnly idiom exactly: a boot-time value set in newServer and never mutated. This is the existing 'thread a boot value on the Server' precedent the task asks for. +- **Server lifetime proof — bootFlags is genuinely process-latched** — `internal/api/supervisor.go:398-416 (getCityServer), New/NewReadOnly at :406/:408` + - shape: `cache keyed by city name; entry reused while `cached.state == state`; a NEW Server (via New/NewReadOnly) is built only when the State pointer changes (city restart).` + - note: controllerState.update() mutates in place (same cs pointer across reloads), so getCityServer keeps the same cached Server and bootFlags stays latched across hot-reloads — exactly the required latch. A pointer swap (true restart) correctly re-resolves at the new boot. +- **State.Config() — the *config.City both api boot-resolve and syncFeatureFlags consume** — `internal/api/state.go:59 (State interface), Config at state.go:60-61` + - shape: `type State interface { Config() *config.City; ... } // controllerState satisfies it via api_state.go:1014` + - note: newServer resolves from state.Config(). Note Config() may return nil in some server modes — Resolve errors on nil cfg, so nil-guard before calling (mirrors syncFeatureFlags' `cfg != nil`). +- **Doctor 'Rollout gates' section — Check contract to implement** — `internal/doctor/types.go:36 (Check), :80 (CheckResult), :55 (CheckContext), :23-32 (Severity)` + - shape: `type Check interface { Name() string; Run(*CheckContext) *CheckResult; CanFix() bool; Fix(*CheckContext) error; WarmupEligible() bool } // CheckResult{ Name; Status(StatusOK/Warning/Error); Severity(SeverityBlocking default | SeverityAdvisory); Message; Details []string; FixHint }` + - note: New file cmd/gc/doctor_rollout_gates.go with a report-only check: CanFix()=false, Fix=no-op, WarmupEligible()=false, Severity=SeverityAdvisory (types.go:31) so it never blocks. Precedent shape: doctor_v2_checks.go / doctor_fork_rate.go (self-contained advisory). +- **Doctor registration point (buildDoctorChecks)** — `cmd/gc/cmd_doctor.go: register(...) block, cfg-guarded region ~:300-347; runner doDoctor at :400-425` + - shape: `buildDoctorChecks(cityPath, cfg, cfgErr, opts) → register(check); config-derived checks are guarded by `if cfgErr == nil && cfg != nil`. Precedent constructors carrying cfg: doctor.NewConfigValidCheck(cfg) (checks.go:109), sessionModelDoctorCheck{cfg:...} (cmd_doctor.go:301).` + - note: Register the rollout-gates check inside the `cfgErr == nil && cfg != nil` block. Doctor already resolves flags implicitly: loadCityConfig (cmd_agent.go:32 → loadCityConfigFS:39, applyFeatureFlags at :52). The check re-resolves purely from cfg for display. +- **Doctor renders on-disk resolution (stateless — NOT the controller latch)** — `rollout.Resolve + rollout.Specs() (registry.go:54) + Flags read surface` + - shape: `flags,err := rollout.Resolve(cfg, rollout.ResolveOptions{}); for _,s := range rollout.Specs() { render s.Key, value (flags.BeadsConditionalWrites()/flags.FormulaV2()), flags.OriginOf(s.Key) }; append flags.Notices() as Details.` + - note: Doctor is a fresh on-disk snapshot, so it can render Origin(builtin/config/env) + env-override notices, but it CANNOT emit NoticePendingRestart (that fact only exists from the running controller's latch-vs-disk compare in root #1). Keep doctor advisory + informational. +- **rollout read-surface signatures (verified)** — `internal/rollout/resolve.go:28,12; flags.go:28,40; flag_beads_conditional_writes.go:15; flag_daemon_formula_v2.go:10; registry.go:54; spec.go:56; mode.go:15-24; notice.go:4-13,32,38` + - shape: `Resolve(cfg *config.City, opts ResolveOptions) (Flags, error); ResolveOptions{ LookupEnv func(string)(string,bool) } (nil=os.LookupEnv); Flags.BeadsConditionalWrites() Mode; Flags.FormulaV2() bool; Flags.OriginOf(key string) Origin; Flags.Notices() []Notice; Specs() []Spec (Spec.Key string); Mode Off|Auto|Require|ModeUnset; Origin OriginBuiltin|OriginConfig|OriginEnv; Notice{Kind NoticeKind, FlagKey, EnvVar, ConfigValue, EnvValue, Message}; NoticePendingRestart NoticeKind` + - note: keyBeadsConditionalWrites="beads.conditional_writes", keyDaemonFormulaV2="daemon.formula_v2" are UNEXPORTED consts; doctor must iterate rollout.Specs() and use Spec.Key (exported) for OriginOf lookups. + + GOTCHAS: + - cmd/gc has NO single boot funnel: applyFeatureFlags runs at 7 non-test sites (cmd_agent.go:52/70, cmd_start.go:673, controller.go:923, cmd_sling.go:247, api_state.go:1808, doctor_provider_catalog.go:146). Only the controllerState-building paths (controller.go:1335, cmd_supervisor.go:2082) create durable process state — the others are transient CLI runs (sling/agent-completion/doctor) that must res + - The two reload paths (api_state.go:1808 loadCurrentConfigSnapshot, controller.go:923 tryReloadConfig) re-call applyFeatureFlags on EVERY reload. For the beads gate you must NOT re-resolve into bootFlags: leave formula_v2 hot-reloadable via legacy applyFeatureFlags, and add a beads compare-vs-latch that emits NoticePendingRestart. update() at api_state.go:617 reassigns cs.cfg under cs.mu.Lock() — e + - Server.bootFlags is only latched because getCityServer (supervisor.go:398-416) caches one Server per State pointer and re-News on pointer change. controllerState.update() mutates in place (pointer stable), so the cached Server survives hot-reload and bootFlags stays latched — good. But confirm no path swaps the State pointer on a mere config reload (that would rebuild the Server and re-latch from + - rollout.Resolve returns (Flags, error) and errors on a nil cfg or an out-of-enum config typo. syncFeatureFlags currently tolerates nil cfg (`cfg != nil`), and State.Config() can be nil in some server modes — nil-guard before Resolve at the api root, and decide boot policy for a config-typo error (warn-and-continue vs fail-start) since a zero Flags{} runs LEGACY paths (Flags zero value is degraded- + - Doctor's Resolve is stateless/on-disk and CANNOT emit NoticePendingRestart (that fact only exists from the controller's latch-vs-disk comparison). The doctor section renders Origin + value + env/config notices only; keep it SeverityAdvisory so it never gates the exit code (doDoctor returns 1 only on report.BlockingFailed>0, cmd_doctor.go:439). + - Gate keys keyBeadsConditionalWrites/keyDaemonFormulaV2 are UNEXPORTED consts in internal/rollout; the doctor check must iterate rollout.Specs() (registry.go:54) and use the exported Spec.Key for Flags.OriginOf lookups — do not hardcode the dotted strings. + - Do not port the formulatest process-mutex or the atomic.Bool init()-to-true default. The rollout.Flags value is per-instance immutable; bootFlags must be a plain value field, never a package global, to keep t.Parallel-safe (the whole point of the redesign). diff --git a/internal/api/server.go b/internal/api/server.go index 6b8cfd7b20..31d5b4ae30 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -11,6 +11,7 @@ import ( "github.com/gastownhall/gascity/internal/config" "github.com/gastownhall/gascity/internal/formula" "github.com/gastownhall/gascity/internal/molecule" + "github.com/gastownhall/gascity/internal/rollout" "github.com/gastownhall/gascity/internal/sling" "github.com/gastownhall/gascity/internal/webhookverify" ) @@ -54,6 +55,12 @@ type Server struct { mux *http.ServeMux readOnly bool // mirrors supervisor's read-only flag for /svc/ enforcement + // bootFlags is the rollout-gate snapshot latched at Server construction — + // from the State's boot latch when it implements RolloutFlagsProvider, else + // resolved once from Config(). Immutable for the Server lifetime, mirroring + // readOnly; the S2+/S3 handler consumers read it. + bootFlags rollout.Flags + // sessionLogSearchPaths overrides the default search paths for Claude // session JSONL files. Nil means use worker.DefaultSearchPaths(). sessionLogSearchPaths []string @@ -214,6 +221,17 @@ func newServer(state State, readOnly bool) *Server { webhookDedup: newWebhookDedupCache(defaultWebhookDedupTTL), webhookLimiter: newWebhookRateLimiter(), } + // Latch the rollout snapshot once: prefer the State's boot latch (the + // production controllerState); fall back to resolving from Config() for + // States without it (test fakes). A Resolve error leaves the zero Flags — + // the documented degraded-safe legacy value; the production root already + // surfaced the error at boot, and this fallback only runs for provider-less + // States, so the error is intentionally not re-surfaced here. + if p, ok := state.(RolloutFlagsProvider); ok { + s.bootFlags = p.RolloutFlags() + } else if cfg := state.Config(); cfg != nil { + s.bootFlags, _ = rollout.Resolve(cfg, rollout.ResolveOptions{}) + } mux.HandleFunc("/svc/", s.handleServiceProxy) // /hook/* webhook receiver — the fourth sanctioned non-Huma surface. Like // /svc/* it is a raw-body pass-through (HMAC/ed25519 sign the exact bytes), diff --git a/internal/api/server_rollout_test.go b/internal/api/server_rollout_test.go new file mode 100644 index 0000000000..85180fc3bb --- /dev/null +++ b/internal/api/server_rollout_test.go @@ -0,0 +1,45 @@ +package api + +import ( + "testing" + + "github.com/gastownhall/gascity/internal/rollout" +) + +// rolloutProviderState is a State that also implements RolloutFlagsProvider, +// exercising the composition-root path where the controller has already +// boot-latched its Flags. +type rolloutProviderState struct { + *fakeState + flags rollout.Flags +} + +func (r rolloutProviderState) RolloutFlags() rollout.Flags { return r.flags } + +var _ RolloutFlagsProvider = rolloutProviderState{} + +// TestServerBootFlagsFromProvider proves newServer prefers a State's already +// latched Flags (the controller's boot value) over re-resolving. +func TestServerBootFlagsFromProvider(t *testing.T) { + want := rollout.ForTest(rollout.WithBeadsConditionalWrites(rollout.Require)) + st := rolloutProviderState{fakeState: newFakeState(t), flags: want} + // A plain fakeState config would resolve to off; the provider must win. + st.cfg.Beads.ConditionalWrites = "off" + + s := newServer(st, false) + if got := s.bootFlags.BeadsConditionalWrites(); got != rollout.Require { + t.Errorf("server bootFlags via provider = %q, want require (provider must win over config)", got) + } +} + +// TestServerBootFlagsFallbackFromConfig proves that a State which does not +// implement RolloutFlagsProvider falls back to resolving from its Config. +func TestServerBootFlagsFallbackFromConfig(t *testing.T) { + fs := newFakeState(t) + fs.cfg.Beads.ConditionalWrites = "require" + + s := newServer(fs, false) + if got := s.bootFlags.BeadsConditionalWrites(); got != rollout.Require { + t.Errorf("server bootFlags fallback from Config = %q, want require", got) + } +} diff --git a/internal/api/state.go b/internal/api/state.go index 2ae96b7181..27ef5d9b6a 100644 --- a/internal/api/state.go +++ b/internal/api/state.go @@ -16,6 +16,7 @@ import ( "github.com/gastownhall/gascity/internal/mail" "github.com/gastownhall/gascity/internal/orderdispatch" "github.com/gastownhall/gascity/internal/orders" + "github.com/gastownhall/gascity/internal/rollout" "github.com/gastownhall/gascity/internal/runtime" "github.com/gastownhall/gascity/internal/supervisor" "github.com/gastownhall/gascity/internal/usage" @@ -258,6 +259,15 @@ type WebhookDispatchProvider interface { WebhookDispatcher() orderdispatch.Dispatcher } +// RolloutFlagsProvider is optionally implemented by State to expose the +// boot-latched rollout-gate snapshot resolved once at controller construction +// (internal/rollout). Modeled on RawConfigProvider/WebhookDispatchProvider so +// the test fakes are not forced to grow it: a State without it gets a +// Resolve-from-Config() fallback at Server construction (see newServer). +type RolloutFlagsProvider interface { + RolloutFlags() rollout.Flags +} + // AgentVisibilityWaiter is an optional capability for states whose Config() // snapshot may briefly lag a successful agent mutation. Callers that need // strict read-after-write semantics for agent target resolution can type-assert diff --git a/internal/rollout/flag_beads_conditional_writes.go b/internal/rollout/flag_beads_conditional_writes.go index cf98d96451..35dcb6a156 100644 --- a/internal/rollout/flag_beads_conditional_writes.go +++ b/internal/rollout/flag_beads_conditional_writes.go @@ -2,8 +2,14 @@ package rollout import "github.com/gastownhall/gascity/internal/config" -// keyBeadsConditionalWrites is the registry Key for the beads CAS rollout gate. -const keyBeadsConditionalWrites = "beads.conditional_writes" +// KeyBeadsConditionalWrites is the exported registry Key for the beads CAS +// rollout gate, so composition-root code (cmd/gc, internal/api) can reference +// the gate without re-hardcoding the dotted string or matching it back out of +// the registry by a coincidental axis. keyBeadsConditionalWrites is the +// package-internal spelling used throughout the resolver and registry. +const KeyBeadsConditionalWrites = "beads.conditional_writes" + +const keyBeadsConditionalWrites = KeyBeadsConditionalWrites // envBeadsConditionalWrites is the single source of truth for this gate's env // override name: the registry Spec.EnvOverride, the resolver, and the diff --git a/internal/rollout/flags.go b/internal/rollout/flags.go index fe20b372a3..38ac390143 100644 --- a/internal/rollout/flags.go +++ b/internal/rollout/flags.go @@ -1,5 +1,7 @@ package rollout +import "strconv" + // resolved pairs a gate's effective value with the layer that produced it. type resolved[T any] struct { value T @@ -36,6 +38,20 @@ func (f Flags) OriginOf(key string) Origin { } } +// ValueOf returns the resolved value of a registered gate Key in its canonical +// string spelling ("" for an unknown key). For doctor/status rendering only — +// production reads use the typed accessors (BeadsConditionalWrites/FormulaV2). +func (f Flags) ValueOf(key string) string { + switch key { + case keyBeadsConditionalWrites: + return string(f.beadsConditionalWrites.value) + case keyDaemonFormulaV2: + return strconv.FormatBool(f.formulaV2.value) + default: + return "" + } +} + // Notices returns the resolution notices retained for the process lifetime. func (f Flags) Notices() []Notice { if len(f.notices) == 0 { diff --git a/internal/rollout/flags_test.go b/internal/rollout/flags_test.go index 7ca108d4e1..6bc0e156fa 100644 --- a/internal/rollout/flags_test.go +++ b/internal/rollout/flags_test.go @@ -1,6 +1,37 @@ package rollout -import "testing" +import ( + "testing" + + "github.com/gastownhall/gascity/internal/config" +) + +// TestValueOf covers the render-only generic value accessor, including the +// binding leg: every registered gate must render a non-empty value, so adding a +// gate without extending ValueOf is caught here (not silently blank in doctor). +func TestValueOf(t *testing.T) { + t.Parallel() + f := ForTest(WithBeadsConditionalWrites(Require), WithFormulaV2(false)) + if got := f.ValueOf(keyBeadsConditionalWrites); got != "require" { + t.Errorf("ValueOf(beads) = %q, want require", got) + } + if got := f.ValueOf(keyDaemonFormulaV2); got != "false" { + t.Errorf("ValueOf(formula_v2) = %q, want false", got) + } + if got := f.ValueOf("nope.nope"); got != "" { + t.Errorf("ValueOf(unknown) = %q, want empty", got) + } + // binding: every registered gate renders non-empty on a resolved Flags. + resolved, err := Resolve(&config.City{}, ResolveOptions{LookupEnv: func(string) (string, bool) { return "", false }}) + if err != nil { + t.Fatal(err) + } + for _, s := range Specs() { + if resolved.ValueOf(s.Key) == "" { + t.Errorf("%s: ValueOf returns empty on a resolved Flags — extend ValueOf for this gate", s.Key) + } + } +} // TestNoticesReturnsDefensiveCopy proves a caller cannot mutate a Flags' retained // notices through the slice Notices() returns. From 57c1fa5df3f9a9d561ca961ede26fafcbbd11642 Mon Sep 17 00:00:00 2001 From: Stephanie Jarmak <36544495+sjarmak@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:03:13 -0400 Subject: [PATCH 043/225] fix(api): bound /status scoped-store resolution by the read timeout (#4122) ## Problem `GET /v0/city/status` resolved its ctx-bound scoped store via `state.ScopedStoreLike(reqCtx, store)` synchronously, before the `select` + `time.After(statusStoreReadTimeout)` guard that bounds the read. `reqCtx` carries the timeout but only binds the resulting bd subprocess runner; the resolution work itself (bd env and managed-dolt connection-state reads, plus any mutex the reconcile loop holds) does not honor `reqCtx`. A slow resolution hung the handler for roughly 20s to 2min, which dragged the supervisor reconcile/dispatch loop along with it. ## Fix Move the `ScopedStoreLike` call into the already-existing timed goroutine at both call sites, `statusSessionSnapshot` and `statusListStoreWithTimeout`, so the same `time.After` budget bounds resolve and read together. On timeout the handler returns a partial/timeout error within `statusStoreReadTimeout` instead of blocking. The subprocess-kill contract is preserved: `defer cancel()` still fires on every return path, so an in-flight bd child is killed rather than leaked past the function's budget. ## Test Plan Two regression tests pin a ctx-blind, slow `ScopedStoreLike` resolution to the read budget: - `TestStatusSessionSnapshotBoundsSlowScopedStoreResolution` drives a resolution that sleeps 3s while `statusStoreReadTimeout` is 200ms and asserts the snapshot returns bounded with a timed-out partial error. - `TestStatusListStoreWithTimeoutBoundsSlowScopedStoreResolution` is the per-rig work-count analog, asserting a bounded return with a timed-out error. - `make build`: pass. - `make check` (lint, vet, full suite): pass. - go-reviewer clean under `-race`. Co-authored-by: sjarmak Co-authored-by: Claude Opus 4.8 (1M context) --- internal/api/handler_status.go | 50 +++++++++------- .../api/handler_status_scoped_store_test.go | 57 +++++++++++++++++++ 2 files changed, 88 insertions(+), 19 deletions(-) diff --git a/internal/api/handler_status.go b/internal/api/handler_status.go index cbfdefa2e3..8595c31baf 100644 --- a/internal/api/handler_status.go +++ b/internal/api/handler_status.go @@ -446,21 +446,12 @@ func (s *Server) statusSessionSnapshot(ctx context.Context) statusSessionSnapsho return snapshot } - // A throwaway, ctx-bound clone of store when it's bd-CLI-backed: on - // timeout below, canceling reqCtx kills an in-flight bd child instead - // of abandoning it to run past this function's return (gascity - // ga-cdmx6x). ScopedStoreLike answers (nil, nil) for non-bd-CLI - // backends, which have no subprocess to leak — those keep reading - // through store directly, unchanged. + // reqCtx bounds the scoped-store read below; defer cancel() fires on + // every return path (including the time.After timeout), killing an + // in-flight bd child instead of leaking it past this function's budget + // (gascity ga-cdmx6x). reqCtx, cancel := context.WithTimeout(ctx, statusStoreReadTimeout) defer cancel() - readStore := store - if scoped, err := s.state.ScopedStoreLike(reqCtx, store); err != nil { - snapshot.partialErrors = []string{fmt.Sprintf("sessions: resolving scoped store: %v", err)} - return snapshot - } else if scoped != nil { - readStore = scoped - } type snapshotResult struct { rows []beads.Bead @@ -469,6 +460,22 @@ func (s *Server) statusSessionSnapshot(ctx context.Context) statusSessionSnapsho } done := make(chan snapshotResult, 1) go func() { + // Resolve the ctx-bound scoped store INSIDE the timed goroutine. + // ScopedStoreLike hands back a bd-CLI-backed clone reqCtx can cancel, + // or (nil, nil) for non-bd backends (native/file/mem) — those read + // through store unchanged. Its resolution (bd env / managed-dolt + // connection state) is synchronous and can block on a mutex the + // reconcile loop holds without honoring reqCtx; kept before the select + // it hung the whole handler past its read budget, dragging the + // supervisor loop (gc-08qgn). Under the goroutine the same time.After + // as the read bounds it. + readStore := store + if scoped, err := s.state.ScopedStoreLike(reqCtx, store); err != nil { + done <- snapshotResult{err: fmt.Errorf("resolving scoped store: %w", err)} + return + } else if scoped != nil { + readStore = scoped + } rows, partialErrors, err := sessionReadModelRows(readStore) done <- snapshotResult{rows: rows, partialErrors: partialErrors, err: err} }() @@ -643,18 +650,23 @@ func statusListStoreWithTimeout(ctx context.Context, state State, store beads.St } reqCtx, cancel := context.WithTimeout(ctx, statusStoreReadTimeout) defer cancel() - readStore := store - if scoped, err := state.ScopedStoreLike(reqCtx, store); err != nil { - return nil, fmt.Errorf("resolving scoped store: %w", err) - } else if scoped != nil { - readStore = scoped - } type listResult struct { rows []beads.Bead err error } done := make(chan listResult, 1) go func() { + // Resolve the ctx-bound scoped store INSIDE the timed goroutine so a + // slow, ctx-blind resolution (a store mutex held by the reconcile + // loop) is bounded by the same time.After as the list instead of + // hanging the handler synchronously (gc-08qgn). + readStore := store + if scoped, err := state.ScopedStoreLike(reqCtx, store); err != nil { + done <- listResult{err: fmt.Errorf("resolving scoped store: %w", err)} + return + } else if scoped != nil { + readStore = scoped + } rows, err := readStore.List(query) done <- listResult{rows: rows, err: err} }() diff --git a/internal/api/handler_status_scoped_store_test.go b/internal/api/handler_status_scoped_store_test.go index a4b3568d01..70fc709ecc 100644 --- a/internal/api/handler_status_scoped_store_test.go +++ b/internal/api/handler_status_scoped_store_test.go @@ -263,6 +263,63 @@ func TestStatusListStoreWithTimeoutKillsBdChildOnTimeout(t *testing.T) { t.Fatalf("bd child process %s survived statusListStoreWithTimeout's timeout", childPid) } +// TestStatusSessionSnapshotBoundsSlowScopedStoreResolution proves the +// scoped-store *resolution* itself (not just the read through it) is bounded +// by statusStoreReadTimeout. ScopedStoreLike resolves the bd env / managed- +// dolt connection state synchronously, and that work can block on a mutex the +// reconcile loop holds without honoring the request ctx (gc-08qgn: /status +// hung ~20s-2min dragging the supervisor loop). A resolution that ignores ctx +// must still not hang the handler past its own read budget. +func TestStatusSessionSnapshotBoundsSlowScopedStoreResolution(t *testing.T) { + oldTimeout := statusStoreReadTimeout + statusStoreReadTimeout = 200 * time.Millisecond + t.Cleanup(func() { statusStoreReadTimeout = oldTimeout }) + + state := newFakeState(t) + state.cityBeadStore = beads.NewMemStore() + // Block for far longer than statusStoreReadTimeout WITHOUT honoring ctx, + // mirroring a ctx-blind mutex acquire in the real env/store resolution. + state.scopedStoreFn = func(context.Context, beads.Store) (beads.Store, error) { + time.Sleep(3 * time.Second) + return nil, nil + } + s := &Server{state: state} + + start := time.Now() + snapshot := s.statusSessionSnapshot(context.Background()) + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Fatalf("statusSessionSnapshot blocked %s on scoped-store resolution; want bounded by statusStoreReadTimeout", elapsed) + } + joined := strings.Join(snapshot.partialErrors, "; ") + if !strings.Contains(joined, "timed out") { + t.Fatalf("partialErrors = %v, want a timed-out entry when resolution exceeds the budget", snapshot.partialErrors) + } +} + +// TestStatusListStoreWithTimeoutBoundsSlowScopedStoreResolution is the +// per-rig work-count analog of the above: a slow, ctx-blind ScopedStoreLike +// resolution must not hang statusListStoreWithTimeout past its read budget. +func TestStatusListStoreWithTimeoutBoundsSlowScopedStoreResolution(t *testing.T) { + oldTimeout := statusStoreReadTimeout + statusStoreReadTimeout = 200 * time.Millisecond + t.Cleanup(func() { statusStoreReadTimeout = oldTimeout }) + + state := newFakeState(t) + state.scopedStoreFn = func(context.Context, beads.Store) (beads.Store, error) { + time.Sleep(3 * time.Second) + return nil, nil + } + + start := time.Now() + _, err := statusListStoreWithTimeout(context.Background(), state, beads.NewMemStore(), beads.ListQuery{AllowScan: true}) + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Fatalf("statusListStoreWithTimeout blocked %s on scoped-store resolution; want bounded by statusStoreReadTimeout", elapsed) + } + if err == nil || !strings.Contains(err.Error(), "timed out") { + t.Fatalf("statusListStoreWithTimeout error = %v, want a timed-out error when resolution exceeds the budget", err) + } +} + func writeExecutableScopedTest(t *testing.T, path, body string) { t.Helper() if err := os.WriteFile(path, []byte(body), 0o755); err != nil { From 53484174350e5cde88a42908f3cb3ba4ea2d99bf Mon Sep 17 00:00:00 2001 From: Stephanie Jarmak <36544495+sjarmak@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:31:54 -0400 Subject: [PATCH 044/225] refactor(beads): move canonical config.yaml types.custom shaping from shell into Go (#4121) ## Problem The canonical `.beads/config.yaml` `types.custom` key was shaped by an `awk`/`sed` shell function, `ensure_types_custom_in_yaml`, in `examples/bd/assets/scripts/gc-beads-bd.sh`. That path had two costs: - **Untestable.** The union/never-narrow/idempotent merge lived entirely in a shell function called from four `op_init` sites. There was no way to exercise it except end-to-end, so the invariants were asserted by shell-string tests that shadowed the real code path rather than running it. - **Duplicated.** The same merge semantics already existed in Go (`internal/doctor/checks_custom_types.go:mergeCustomTypes`). Two copies of one invariant drift apart; the shell copy is the one that actually writes the file. ## Approach Move ownership of the canonical `types.custom` line into the Go contract layer, where it can carry typed invariants and real tests. - `contract.ConfigState` gains a `CustomTypes` field. `EnsureCanonicalConfig` and its malformed-YAML fallback union it with whatever is already on disk: current entries first, required appended, deduplicated, never narrowing an existing superset, and a no-op when the effective set already matches (no mtime churn for file watchers). - The malformed-YAML repair path strips quotes before merging, so a corrupt `types.custom: "a,b"` line cannot poison the union. - Injection happens once, at the single `ensureCanonicalScopeConfigState` init/sweep funnel, sourced from `doctor.RequiredCustomTypes` through the shared `contract.MergeCustomTypes`. - `contract.MergeCustomTypes` is exported and doctor's duplicate copy deleted, so there is one merge implementation. - `ensure_types_custom_in_yaml` and its four `op_init` call sites are removed from the script. The runtime SQL path (`ensure_bd_runtime_custom_types`, which writes bd's DB config table rather than the file) is unchanged and out of scope. The `write_doltlite_metadata` / `metadata.json` half of the shell shaping is intentionally deferred to a follow-up: the init metadata path has to become backend-aware before it can move, and folding both into one change would couple two unrelated migrations. ## Test Plan - New `internal/beads/contract/files_custom_types_test.go`: merge, idempotence, never-narrow, malformed-YAML fallback union, and quote-strip regressions. These replace the four deleted shell-function tests and exercise the real code path. - New `cmd/gc/beads_provider_custom_types_test.go`: funnel-level init regression that confirms `ensureCanonicalScopeConfigState` injects the required types across backends. - Deleted the shell-string tests in `cmd/gc/gc_beads_bd_yaml_test.go` that covered the removed shell function. - `make build` and `make check` green on the rebased branch. Co-authored-by: sjarmak Co-authored-by: Claude Opus 4.8 (1M context) --- cmd/gc/beads_provider_custom_types_test.go | 94 ++++++ cmd/gc/beads_provider_lifecycle.go | 7 + cmd/gc/gc_beads_bd_yaml_test.go | 223 -------------- examples/bd/assets/scripts/gc-beads-bd.sh | 67 +---- internal/beads/contract/files.go | 85 ++++++ .../beads/contract/files_custom_types_test.go | 273 ++++++++++++++++++ internal/doctor/checks_custom_types.go | 31 +- internal/doctor/checks_custom_types_test.go | 6 +- 8 files changed, 468 insertions(+), 318 deletions(-) create mode 100644 cmd/gc/beads_provider_custom_types_test.go delete mode 100644 cmd/gc/gc_beads_bd_yaml_test.go create mode 100644 internal/beads/contract/files_custom_types_test.go diff --git a/cmd/gc/beads_provider_custom_types_test.go b/cmd/gc/beads_provider_custom_types_test.go new file mode 100644 index 0000000000..dd507b3016 --- /dev/null +++ b/cmd/gc/beads_provider_custom_types_test.go @@ -0,0 +1,94 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gastownhall/gascity/internal/beads/contract" + "github.com/gastownhall/gascity/internal/doctor" + "github.com/gastownhall/gascity/internal/fsys" +) + +// ensureCanonicalScopeConfigState is the single funnel every managed-scope +// canonical config.yaml write routes through — both the init path +// (normalizeCanonicalBdScopeFilesForInit / seedDeferredManagedBeadsErr) and the +// post-init sweep (normalizeScopeDoltConfig). These tests prove Go now owns the +// canonical `types.custom` shaping that gc-beads-bd.sh's ensure_types_custom_in_yaml +// used to do, across every backend that routes through the funnel. + +func scopeConfigPath(dir string) string { + return filepath.Join(dir, ".beads", "config.yaml") +} + +// A fresh scope (no config.yaml) must end up with every doctor.RequiredCustomTypes +// registered in types.custom, written by Go without the shell touching the file. +func TestEnsureCanonicalScopeConfigStateInjectsRequiredCustomTypes(t *testing.T) { + dir := t.TempDir() + + if err := ensureCanonicalScopeConfigState(fsys.OSFS{}, dir, contract.ConfigState{IssuePrefix: "gc"}); err != nil { + t.Fatalf("ensureCanonicalScopeConfigState() error = %v", err) + } + + data, err := os.ReadFile(scopeConfigPath(dir)) + if err != nil { + t.Fatalf("read config.yaml: %v", err) + } + got := string(data) + value, ok := scanTypesCustomLine(got) + if !ok { + t.Fatalf("config.yaml has no types.custom line:\n%s", got) + } + set := make(map[string]bool) + for _, e := range strings.Split(value, ",") { + set[strings.TrimSpace(e)] = true + } + for _, req := range doctor.RequiredCustomTypes { + if !set[req] { + t.Errorf("config.yaml types.custom missing required type %q (got %q)", req, value) + } + } +} + +// A scope carrying an operator/pack custom type beyond the baseline must keep it +// after Go canonicalization — the never-narrow guarantee inherited from the shell. +func TestEnsureCanonicalScopeConfigStatePreservesExistingCustomTypes(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, ".beads"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(scopeConfigPath(dir), []byte("issue_prefix: gc\ntypes.custom: pack_special\n"), 0o644); err != nil { + t.Fatal(err) + } + + if err := ensureCanonicalScopeConfigState(fsys.OSFS{}, dir, contract.ConfigState{IssuePrefix: "gc"}); err != nil { + t.Fatalf("ensureCanonicalScopeConfigState() error = %v", err) + } + + data, err := os.ReadFile(scopeConfigPath(dir)) + if err != nil { + t.Fatalf("read config.yaml: %v", err) + } + value, ok := scanTypesCustomLine(string(data)) + if !ok { + t.Fatalf("config.yaml has no types.custom line:\n%s", data) + } + if !strings.Contains(value, "pack_special") { + t.Errorf("types.custom narrowed away operator type pack_special: got %q", value) + } + for _, req := range doctor.RequiredCustomTypes { + if !strings.Contains(value, req) { + t.Errorf("types.custom missing required type %q: got %q", req, value) + } + } +} + +func scanTypesCustomLine(text string) (string, bool) { + for _, line := range strings.Split(text, "\n") { + if strings.HasPrefix(line, "types.custom:") { + return strings.TrimSpace(strings.TrimPrefix(line, "types.custom:")), true + } + } + return "", false +} diff --git a/cmd/gc/beads_provider_lifecycle.go b/cmd/gc/beads_provider_lifecycle.go index 3f32c1fb08..bbe1dfb03b 100644 --- a/cmd/gc/beads_provider_lifecycle.go +++ b/cmd/gc/beads_provider_lifecycle.go @@ -22,6 +22,7 @@ import ( "github.com/gastownhall/gascity/internal/beads/contract" "github.com/gastownhall/gascity/internal/citylayout" "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/doctor" "github.com/gastownhall/gascity/internal/fsys" "github.com/gastownhall/gascity/internal/pidutil" ) @@ -376,6 +377,12 @@ func ensureCanonicalScopeConfigState(fs fsys.FS, dir string, state contract.Conf if err := ensureBeadsDir(fs, beadsDir); err != nil { return err } + // Go owns canonical types.custom shaping (formerly gc-beads-bd.sh's + // ensure_types_custom_in_yaml). doctor.RequiredCustomTypes is the single + // source; union (not replace) so the baseline is always present even if a + // future caller supplies its own extra types, and EnsureCanonicalConfig + // then unions the result with any on-disk extensions. + state.CustomTypes = contract.MergeCustomTypes(state.CustomTypes, doctor.RequiredCustomTypes) changed, err := contract.EnsureCanonicalConfig(fs, filepath.Join(beadsDir, "config.yaml"), state) if err != nil { return err diff --git a/cmd/gc/gc_beads_bd_yaml_test.go b/cmd/gc/gc_beads_bd_yaml_test.go deleted file mode 100644 index 1f48168070..0000000000 --- a/cmd/gc/gc_beads_bd_yaml_test.go +++ /dev/null @@ -1,223 +0,0 @@ -package main - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -// TestGcBeadsBdEnsureTypesCustomInYaml_MergesWithExistingValues pins the -// gascity-side #2154 fix and the PR #2315 review followup: when the -// existing types.custom line is a different set than the baseline being -// installed, the function must MERGE the two sets (preserving existing -// entries that may be pack/user-defined custom types) rather than overwrite. -// The required baseline types must end up present after the call; the -// existing entries must also remain. -func TestGcBeadsBdEnsureTypesCustomInYaml_MergesWithExistingValues(t *testing.T) { - if _, err := exec.LookPath("bash"); err != nil { - t.Skip("bash not available; skipping shell-function test") - } - cityDir := t.TempDir() - if err := os.MkdirAll(filepath.Join(cityDir, ".beads"), 0o755); err != nil { - t.Fatalf("MkdirAll: %v", err) - } - yamlPath := filepath.Join(cityDir, ".beads", "config.yaml") - // Existing values represent extensions the operator/pack added beyond - // the SDK baseline — they must be preserved through the merge. - initial := "issue_prefix: gc\ntypes.custom: legacy_a,legacy_b,legacy_c\n" - if err := os.WriteFile(yamlPath, []byte(initial), 0o644); err != nil { - t.Fatalf("WriteFile(initial): %v", err) - } - - materializeBuiltinPacksForTest(t, cityDir) - script := bundledGcBeadsBdScriptForTest(t) - - desiredTypes := "alpha,beta,gamma" - // Source just the function definition out of the script and call it. - // We extract via awk rather than sourcing the whole file because the - // script's main block at the bottom runs unconditionally. - bashCmd := fmt.Sprintf(` -set -e -eval "$(awk '/^ensure_types_custom_in_yaml\(\)/,/^}/' %q)" -ensure_types_custom_in_yaml %q %q -`, script, cityDir, desiredTypes) - - out, err := exec.Command("bash", "-c", bashCmd).CombinedOutput() - if err != nil { - t.Fatalf("ensure_types_custom_in_yaml: %v\n%s", err, out) - } - - data, err := os.ReadFile(yamlPath) - if err != nil { - t.Fatalf("ReadFile(after): %v", err) - } - got := string(data) - // All baseline types must land. - for _, must := range []string{"alpha", "beta", "gamma"} { - if !strings.Contains(got, must) { - t.Errorf("config.yaml missing required baseline type %q after merge:\n%s", must, got) - } - } - // All existing entries must be preserved. - for _, must := range []string{"legacy_a", "legacy_b", "legacy_c"} { - if !strings.Contains(got, must) { - t.Errorf("config.yaml lost existing type %q after merge:\n%s", must, got) - } - } -} - -// TestGcBeadsBdEnsureTypesCustomInYaml_IdempotentWhenMatching pins the -// other half of the contract: when the existing line matches the desired -// value exactly, the function must be a no-op (no rewrite, no mtime change -// noise that downstream watchers would interpret as a change). -func TestGcBeadsBdEnsureTypesCustomInYaml_IdempotentWhenMatching(t *testing.T) { - if _, err := exec.LookPath("bash"); err != nil { - t.Skip("bash not available; skipping shell-function test") - } - cityDir := t.TempDir() - if err := os.MkdirAll(filepath.Join(cityDir, ".beads"), 0o755); err != nil { - t.Fatalf("MkdirAll: %v", err) - } - yamlPath := filepath.Join(cityDir, ".beads", "config.yaml") - desiredTypes := "alpha,beta,gamma" - initial := "issue_prefix: gc\ntypes.custom: " + desiredTypes + "\n" - if err := os.WriteFile(yamlPath, []byte(initial), 0o644); err != nil { - t.Fatalf("WriteFile(initial): %v", err) - } - infoBefore, err := os.Stat(yamlPath) - if err != nil { - t.Fatalf("Stat(before): %v", err) - } - - materializeBuiltinPacksForTest(t, cityDir) - script := bundledGcBeadsBdScriptForTest(t) - - bashCmd := fmt.Sprintf(` -set -e -eval "$(awk '/^ensure_types_custom_in_yaml\(\)/,/^}/' %q)" -ensure_types_custom_in_yaml %q %q -`, script, cityDir, desiredTypes) - - out, err := exec.Command("bash", "-c", bashCmd).CombinedOutput() - if err != nil { - t.Fatalf("ensure_types_custom_in_yaml: %v\n%s", err, out) - } - - data, err := os.ReadFile(yamlPath) - if err != nil { - t.Fatalf("ReadFile(after): %v", err) - } - if string(data) != initial { - t.Fatalf("config.yaml after idempotent call changed:\nbefore: %q\nafter: %q", initial, string(data)) - } - infoAfter, err := os.Stat(yamlPath) - if err != nil { - t.Fatalf("Stat(after): %v", err) - } - if !infoBefore.ModTime().Equal(infoAfter.ModTime()) { - t.Fatalf("config.yaml mtime changed on idempotent call (before=%v after=%v) — function should short-circuit when value matches", - infoBefore.ModTime(), infoAfter.ModTime()) - } -} - -// TestGcBeadsBdEnsureTypesCustomInYaml_PreservesCustomExtensions pins the -// PR #2315 review fix: when the existing types.custom line contains -// pack/user-defined types beyond the GC baseline (the desiredTypes the -// caller passes), the function must MERGE — preserving the extensions — -// not narrow the set to just the baseline. The previous behavior treated -// any non-exact match as stale and rewrote with $types alone, silently -// dropping pack/user types and breaking later bead creation for those -// types. Mirrors mergeCustomTypes in -// internal/doctor/checks_custom_types.go. -func TestGcBeadsBdEnsureTypesCustomInYaml_PreservesCustomExtensions(t *testing.T) { - if _, err := exec.LookPath("bash"); err != nil { - t.Skip("bash not available; skipping shell-function test") - } - cityDir := t.TempDir() - if err := os.MkdirAll(filepath.Join(cityDir, ".beads"), 0o755); err != nil { - t.Fatalf("MkdirAll: %v", err) - } - yamlPath := filepath.Join(cityDir, ".beads", "config.yaml") - // Existing line: GC baseline + 2 pack-defined extensions. - initial := "issue_prefix: gc\ntypes.custom: alpha,beta,pack_custom_a,pack_custom_b\n" - if err := os.WriteFile(yamlPath, []byte(initial), 0o644); err != nil { - t.Fatalf("WriteFile(initial): %v", err) - } - - materializeBuiltinPacksForTest(t, cityDir) - script := bundledGcBeadsBdScriptForTest(t) - - // Caller passes only the baseline. The merge must keep pack_custom_a - // and pack_custom_b — narrowing the set would defeat the doctor-merge - // contract internal/doctor/checks_custom_types.go encodes. - desiredTypes := "alpha,beta" - bashCmd := fmt.Sprintf(` -set -e -eval "$(awk '/^ensure_types_custom_in_yaml\(\)/,/^}/' %q)" -ensure_types_custom_in_yaml %q %q -`, script, cityDir, desiredTypes) - - out, err := exec.Command("bash", "-c", bashCmd).CombinedOutput() - if err != nil { - t.Fatalf("ensure_types_custom_in_yaml: %v\n%s", err, out) - } - - data, err := os.ReadFile(yamlPath) - if err != nil { - t.Fatalf("ReadFile(after): %v", err) - } - got := string(data) - for _, must := range []string{"alpha", "beta", "pack_custom_a", "pack_custom_b"} { - if !strings.Contains(got, must) { - t.Errorf("config.yaml lost custom type %q after merge:\n%s", must, got) - } - } -} - -// TestGcBeadsBdEnsureTypesCustomInYaml_AddsMissingBaselineToCustomSet -// pins the other half of the merge: a YAML containing ONLY pack/user -// extensions (no overlap with the baseline) must end up with both the -// extensions AND the baseline after a call with the GC types. -func TestGcBeadsBdEnsureTypesCustomInYaml_AddsMissingBaselineToCustomSet(t *testing.T) { - if _, err := exec.LookPath("bash"); err != nil { - t.Skip("bash not available; skipping shell-function test") - } - cityDir := t.TempDir() - if err := os.MkdirAll(filepath.Join(cityDir, ".beads"), 0o755); err != nil { - t.Fatalf("MkdirAll: %v", err) - } - yamlPath := filepath.Join(cityDir, ".beads", "config.yaml") - initial := "issue_prefix: gc\ntypes.custom: pack_only_a,pack_only_b\n" - if err := os.WriteFile(yamlPath, []byte(initial), 0o644); err != nil { - t.Fatalf("WriteFile(initial): %v", err) - } - - materializeBuiltinPacksForTest(t, cityDir) - script := bundledGcBeadsBdScriptForTest(t) - - desiredTypes := "alpha,beta,gamma" - bashCmd := fmt.Sprintf(` -set -e -eval "$(awk '/^ensure_types_custom_in_yaml\(\)/,/^}/' %q)" -ensure_types_custom_in_yaml %q %q -`, script, cityDir, desiredTypes) - - out, err := exec.Command("bash", "-c", bashCmd).CombinedOutput() - if err != nil { - t.Fatalf("ensure_types_custom_in_yaml: %v\n%s", err, out) - } - - data, err := os.ReadFile(yamlPath) - if err != nil { - t.Fatalf("ReadFile(after): %v", err) - } - got := string(data) - for _, must := range []string{"alpha", "beta", "gamma", "pack_only_a", "pack_only_b"} { - if !strings.Contains(got, must) { - t.Errorf("config.yaml missing expected type %q after merge:\n%s", must, got) - } - } -} diff --git a/examples/bd/assets/scripts/gc-beads-bd.sh b/examples/bd/assets/scripts/gc-beads-bd.sh index 96404b9c94..61bb17fbe0 100755 --- a/examples/bd/assets/scripts/gc-beads-bd.sh +++ b/examples/bd/assets/scripts/gc-beads-bd.sh @@ -685,65 +685,6 @@ wait_for_bd_runtime_schema() { return 1 } -# ensure_types_custom_in_yaml writes types.custom to .beads/config.yaml. -# bd reads this YAML key as a fallback when the database config table is -# unset (see beads internal/config: GetCustomTypesFromYAML), so writing -# here registers the types without paying bd's per-command auto-migrate -# cost (~50s on populated databases). -# -# Idempotent against the desired effective set: re-running with the SAME -# baseline is a no-op. The rewrite NEVER narrows the type set: if the YAML -# already contains pack-defined or user-defined custom types beyond $types -# (the GC baseline), those extensions are preserved. This matches the -# merge semantics of internal/doctor/checks_custom_types.go:mergeCustomTypes -# and fixes the gascity-side failure surfaced in #2154 — a stale or partial -# line is replaced with the union of existing and required entries, never -# overwritten with just the baseline. -ensure_types_custom_in_yaml() { - local dir="$1" - local types="$2" - local config_yaml="$dir/.beads/config.yaml" - [ -f "$config_yaml" ] || return 0 - [ -n "$types" ] || return 0 - - local current - current=$(sed -n 's/^types\.custom: *//p' "$config_yaml" 2>/dev/null | head -1) - - local merged - merged=$(printf '%s,%s' "$current" "$types" | awk -F, ' - { - for (i = 1; i <= NF; i++) { - t = $i - sub(/^[ \t]+/, "", t) - sub(/[ \t]+$/, "", t) - gsub(/"/, "", t) - sub(/^[ \t]+/, "", t) - sub(/[ \t]+$/, "", t) - if (t == "") continue - if (!(t in seen)) { - seen[t] = 1 - out = (out == "" ? t : out "," t) - } - } - print out - } - ') - - # Short-circuit when the merged set already equals what's on disk: - # avoids mtime churn that downstream watchers might misread as a real - # change. Includes the case where current is already a superset of - # the baseline (operator/pack types appended to the GC list). - if [ "$current" = "$merged" ]; then - return 0 - fi - - local tmp - tmp=$(mktemp "$config_yaml.tmp.XXXXXX") || return 0 - sed '/^types\.custom:/d' "$config_yaml" > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 0; } - printf 'types.custom: %s\n' "$merged" >> "$tmp" - mv -f "$tmp" "$config_yaml" || rm -f "$tmp" -} - # --- Robustness Helpers --- # save_state writes the private provider runtime state atomically (no jq dependency). @@ -2731,7 +2672,6 @@ op_init() { run_bd_init_pinned "$dir" "$prefix" "$dolt_database" "$hosted_host" "" fi ensure_beads_dir_permissions "$dir" - ensure_types_custom_in_yaml "$dir" "$custom_types" exit 0 fi @@ -2755,7 +2695,6 @@ op_init() { if [ "$already_ready" = true ]; then run_doltlite_existing_db_maintenance "$dir" fi - ensure_types_custom_in_yaml "$dir" "$custom_types" exit 0 fi @@ -2792,7 +2731,6 @@ op_init() { # and bd-specific bootstrap only. ensure_beads_dir_permissions "$dir" normalize_scope_after_init "$dir" "$prefix" "$dolt_database" - ensure_types_custom_in_yaml "$dir" "$custom_types" ensure_bd_runtime_custom_types "$dolt_database" "$custom_types" ensure_bd_runtime_issue_prefix "$dolt_database" "$prefix" ensure_project_identity "$dir" @@ -2861,8 +2799,9 @@ op_init() { fi # Configure custom bead types without invoking `bd config set`, which can - # spend tens of seconds in auto-migrate on populated stores. - ensure_types_custom_in_yaml "$dir" "$custom_types" + # spend tens of seconds in auto-migrate on populated stores. The canonical + # .beads/config.yaml types.custom line is now Go-owned (EnsureCanonicalConfig); + # here we only register the types in bd's runtime SQL config table. ensure_bd_runtime_custom_types "$dolt_database" "$custom_types" # Keep bd's runtime config in sync with GC's canonical prefix. This is diff --git a/internal/beads/contract/files.go b/internal/beads/contract/files.go index 8cdff0fb4a..bac2d465a3 100644 --- a/internal/beads/contract/files.go +++ b/internal/beads/contract/files.go @@ -48,6 +48,19 @@ type ConfigState struct { // When empty, the existing dolt.mode value is preserved. DoltMode string Dolt DoltConfig + // CustomTypes is a caller-supplied list of bd custom bead types to ensure + // in the canonical `types.custom` config key. When non-empty, + // EnsureCanonicalConfig unions these with any types already on disk + // (never narrowing — pre-existing entries are preserved) and writes the + // merged `types.custom: a,b,c` line. When empty, the existing + // `types.custom` value is left untouched (passthrough). The list itself is + // opaque to this package; cmd/gc sources it from doctor.RequiredCustomTypes. + // + // This is the Go-owned replacement for gc-beads-bd.sh's former + // ensure_types_custom_in_yaml shell function; bd reads this YAML key as a + // fallback when its DB config table is unset, so materializing it here + // avoids bd's per-command auto-migrate cost on populated stores. + CustomTypes []string } // DoltConfig is the Dolt-specific subset of .beads/config.yaml that GC owns. @@ -525,6 +538,18 @@ func EnsureCanonicalConfig(fs fsys.FS, path string, state ConfigState) (bool, er changed = setString(root, "dolt.mode", mode) || changed } + if len(state.CustomTypes) > 0 { + // Union with what's already on disk, never narrowing — pack/operator + // custom types beyond the GC baseline must survive. `types.custom` is a + // flat dotted top-level key (not nested `types: {custom:}`); this reads + // and writes that same flat form the shell and bd emit. + existing, _ := configStringValue(root, "types.custom") + merged := MergeCustomTypes(parseCustomTypesValue(existing), state.CustomTypes) + if len(merged) > 0 { + changed = setString(root, "types.custom", strings.Join(merged, ",")) || changed + } + } + changed = deleteKeys(root, deprecatedConfigKeys...) || changed if !changed { return false, nil @@ -665,6 +690,16 @@ func ensureCanonicalConfigFallback(fs fsys.FS, path string, state ConfigState) ( if mode := strings.TrimSpace(state.DoltMode); mode != "" { replacements["dolt.mode"] = "dolt.mode: " + mode } + if len(state.CustomTypes) > 0 { + // Same never-narrow union as the main path, but sourced from the raw + // (post-repair) bytes: bd init emits a glued `sync.remote: "…"types.custom: …` + // line that routes here, and the shell's ensure_types_custom_in_yaml + // unioned regardless of YAML validity — so the fallback must too. + existing, _ := scanConfigLineValueFromData(data, "types.custom:") + if merged := MergeCustomTypes(parseCustomTypesValue(existing), state.CustomTypes); len(merged) > 0 { + replacements["types.custom"] = "types.custom: " + strings.Join(merged, ",") + } + } disableEventFlush := doltDisableEventFlushFallbackValue(data, state) lines := strings.Split(string(data), "\n") @@ -720,6 +755,7 @@ func ensureCanonicalConfigFallback(fs fsys.FS, path string, state ConfigState) ( "dolt.port", "dolt.user", "dolt.mode", + "types.custom", } for _, key := range orderedKeys { want, ok := replacements[key] @@ -742,6 +778,55 @@ func ensureCanonicalConfigFallback(fs fsys.FS, path string, state ConfigState) ( return true, fs.WriteFile(path, []byte(strings.Join(out, "\n")), 0o644) } +// parseCustomTypesValue splits a raw `types.custom` value ("a,b,c") into +// trimmed, unquoted, non-empty entries. A blank value yields nil. +// +// Quote stripping matters for the malformed-YAML fallback path, which scans +// raw bytes rather than YAML-unquoted node values: a quoted `types.custom: +// "alpha,beta"` line splits on the comma into `"alpha` and `beta"`, so each +// entry must have its quote characters removed before comparison — otherwise +// the union never matches the required set and re-appends corrupted duplicates. +// Mirrors the deleted shell function's `gsub(/"/, "", t)`. +func parseCustomTypesValue(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if t := strings.TrimSpace(strings.ReplaceAll(p, `"`, "")); t != "" { + out = append(out, t) + } + } + return out +} + +// MergeCustomTypes returns the union of current and required, current entries +// first (preserving on-disk order), then any required entries not already +// present. Empty/whitespace-only entries are dropped and duplicates removed. +// Current-first ordering matches the shell's former merge, so re-running +// against an unchanged set produces the identical value and setString +// short-circuits (no mtime churn). Exported so higher layers (e.g. doctor) +// share this one implementation rather than duplicating the union algorithm. +func MergeCustomTypes(current, required []string) []string { + seen := make(map[string]bool, len(current)+len(required)) + merged := make([]string, 0, len(current)+len(required)) + add := func(list []string) { + for _, t := range list { + t = strings.TrimSpace(t) + if t == "" || seen[t] { + continue + } + seen[t] = true + merged = append(merged, t) + } + } + add(current) + add(required) + return merged +} + func isConfigParseError(err error) bool { var target *configParseError return errors.As(err, &target) diff --git a/internal/beads/contract/files_custom_types_test.go b/internal/beads/contract/files_custom_types_test.go new file mode 100644 index 0000000000..dc7e66c022 --- /dev/null +++ b/internal/beads/contract/files_custom_types_test.go @@ -0,0 +1,273 @@ +package contract + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/gastownhall/gascity/internal/fsys" +) + +// These tests pin the canonical `types.custom` contract that EnsureCanonicalConfig +// now owns, moved out of gc-beads-bd.sh's former ensure_types_custom_in_yaml +// shell function (gascity #2154 / PR #2315 review followup). The shell function +// and its four op_init call sites were deleted; these Go tests carry its +// never-narrow merge + idempotency guarantees forward. + +func readConfigFile(t *testing.T, path string) string { + t.Helper() + data, err := (fsys.OSFS{}).ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%s): %v", path, err) + } + return string(data) +} + +// Merging a different existing set with the baseline must yield the union: +// existing entries (possibly pack/user-defined) are preserved and the baseline +// lands too. +func TestEnsureCanonicalConfigMergesCustomTypesWithExisting(t *testing.T) { + fs := fsys.OSFS{} + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := fs.WriteFile(path, []byte("issue_prefix: gc\ntypes.custom: legacy_a,legacy_b,legacy_c\n"), 0o644); err != nil { + t.Fatal(err) + } + + changed, err := EnsureCanonicalConfig(fs, path, ConfigState{ + IssuePrefix: "gc", + CustomTypes: []string{"alpha", "beta", "gamma"}, + }) + if err != nil { + t.Fatalf("EnsureCanonicalConfig() error = %v", err) + } + if !changed { + t.Fatal("EnsureCanonicalConfig() should report a change when merging new baseline types") + } + + got := readConfigFile(t, path) + for _, must := range []string{"legacy_a", "legacy_b", "legacy_c", "alpha", "beta", "gamma"} { + if !strings.Contains(got, must) { + t.Errorf("config.yaml missing type %q after merge:\n%s", must, got) + } + } + // Current-first ordering: existing entries precede newly-added baseline. + value, ok := scanConfigLineValueFromData([]byte(got), "types.custom:") + if !ok { + t.Fatalf("types.custom line missing:\n%s", got) + } + if want := "legacy_a,legacy_b,legacy_c,alpha,beta,gamma"; value != want { + t.Fatalf("types.custom = %q, want %q", value, want) + } +} + +// When the on-disk value already equals the merged set, EnsureCanonicalConfig +// must not rewrite the key — no change reported, byte-identical file (the shell +// short-circuited to avoid mtime churn downstream watchers misread). +func TestEnsureCanonicalConfigCustomTypesIdempotentWhenMatching(t *testing.T) { + fs := fsys.OSFS{} + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + baseline := []string{"alpha", "beta", "gamma"} + // Prime the file to the fully-canonical form so only types.custom is under test. + if _, err := EnsureCanonicalConfig(fs, path, ConfigState{IssuePrefix: "gc", CustomTypes: baseline}); err != nil { + t.Fatal(err) + } + before := readConfigFile(t, path) + + changed, err := EnsureCanonicalConfig(fs, path, ConfigState{IssuePrefix: "gc", CustomTypes: baseline}) + if err != nil { + t.Fatalf("second EnsureCanonicalConfig() error = %v", err) + } + if changed { + t.Fatalf("EnsureCanonicalConfig() should be idempotent for an unchanged types.custom:\n%s", before) + } + if after := readConfigFile(t, path); after != before { + t.Fatalf("config.yaml changed on idempotent call:\nbefore: %q\nafter: %q", before, after) + } +} + +// Never narrow: when the caller passes only the baseline but the file carries +// pack/user extensions beyond it, those extensions must survive. +func TestEnsureCanonicalConfigCustomTypesPreservesExtensions(t *testing.T) { + fs := fsys.OSFS{} + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := fs.WriteFile(path, []byte("issue_prefix: gc\ntypes.custom: alpha,beta,pack_custom_a,pack_custom_b\n"), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := EnsureCanonicalConfig(fs, path, ConfigState{IssuePrefix: "gc", CustomTypes: []string{"alpha", "beta"}}); err != nil { + t.Fatalf("EnsureCanonicalConfig() error = %v", err) + } + + got := readConfigFile(t, path) + for _, must := range []string{"alpha", "beta", "pack_custom_a", "pack_custom_b"} { + if !strings.Contains(got, must) { + t.Errorf("config.yaml narrowed away custom type %q:\n%s", must, got) + } + } +} + +// A file carrying only extensions (no overlap with the baseline) must end up +// with both the extensions and the full baseline. +func TestEnsureCanonicalConfigCustomTypesAddsMissingBaseline(t *testing.T) { + fs := fsys.OSFS{} + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := fs.WriteFile(path, []byte("issue_prefix: gc\ntypes.custom: pack_only_a,pack_only_b\n"), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := EnsureCanonicalConfig(fs, path, ConfigState{IssuePrefix: "gc", CustomTypes: []string{"alpha", "beta", "gamma"}}); err != nil { + t.Fatalf("EnsureCanonicalConfig() error = %v", err) + } + + got := readConfigFile(t, path) + for _, must := range []string{"pack_only_a", "pack_only_b", "alpha", "beta", "gamma"} { + if !strings.Contains(got, must) { + t.Errorf("config.yaml missing expected type %q:\n%s", must, got) + } + } +} + +// When CustomTypes is empty the key is untouched — existing callers that do not +// opt in keep today's passthrough behavior (no types.custom management). +func TestEnsureCanonicalConfigCustomTypesEmptyIsPassthrough(t *testing.T) { + fs := fsys.OSFS{} + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := fs.WriteFile(path, []byte("issue_prefix: gc\ntypes.custom: only_existing\n"), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := EnsureCanonicalConfig(fs, path, ConfigState{IssuePrefix: "gc"}); err != nil { + t.Fatalf("EnsureCanonicalConfig() error = %v", err) + } + + got := readConfigFile(t, path) + if value, _ := scanConfigLineValueFromData([]byte(got), "types.custom:"); value != "only_existing" { + t.Fatalf("types.custom must be untouched when CustomTypes empty, got %q:\n%s", value, got) + } +} + +// The fallback (malformed-YAML repair) path must also union CustomTypes: bd init +// emits a glued `sync.remote: "…"types.custom: …` line that routes through +// ensureCanonicalConfigFallback, and the old shell function unioned regardless +// of YAML validity, so parity requires the same here. +func TestEnsureCanonicalConfigCustomTypesUnionsInFallback(t *testing.T) { + fs := fsys.OSFS{} + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + input := strings.Join([]string{ + "issue_prefix: si", + "issue-prefix: si", + `sync.remote: "git+ssh://git@example.com/foo/svc.git" types.custom: alpha,pack_extra`, + "", + }, "\n") + if err := fs.WriteFile(path, []byte(input), 0o644); err != nil { + t.Fatal(err) + } + + changed, err := EnsureCanonicalConfig(fs, path, ConfigState{ + IssuePrefix: "si", + CustomTypes: []string{"alpha", "beta"}, + }) + if err != nil { + t.Fatalf("EnsureCanonicalConfig() error = %v", err) + } + if !changed { + t.Fatal("EnsureCanonicalConfig() should report a change repairing+unioning the glued line") + } + + got := readConfigFile(t, path) + // Must parse as YAML after repair. + if _, err := readConfigDoc(fs, path); err != nil { + t.Fatalf("repaired config must parse as YAML, got %v\n%s", err, got) + } + // Union preserves the on-disk extension and adds the missing baseline. + value, ok := scanConfigLineValueFromData([]byte(got), "types.custom:") + if !ok { + t.Fatalf("types.custom missing after fallback repair:\n%s", got) + } + for _, must := range []string{"alpha", "pack_extra", "beta"} { + if !strings.Contains(value, must) { + t.Errorf("fallback types.custom %q missing %q", value, must) + } + } + occurrences := 0 + for _, line := range strings.Split(got, "\n") { + if strings.HasPrefix(line, "types.custom:") { + occurrences++ + } + } + if occurrences != 1 { + t.Fatalf("types.custom should appear exactly once after fallback, found %d:\n%s", occurrences, got) + } +} + +// A malformed line whose types.custom VALUE is quoted must not corrupt the +// merge: scanning raw bytes yields `"alpha,beta"`, which splits into `"alpha` +// and `beta"`. Without quote-stripping those never match the required set and +// get re-appended as garbage duplicates (the #2154 corruption, in the repair +// path). parseCustomTypesValue strips quotes to prevent this. +func TestEnsureCanonicalConfigCustomTypesFallbackStripsQuotes(t *testing.T) { + fs := fsys.OSFS{} + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + input := strings.Join([]string{ + "issue_prefix: si", + "issue-prefix: si", + `sync.remote: "git+ssh://git@example.com/foo/svc.git" types.custom: "alpha,beta"`, + "", + }, "\n") + if err := fs.WriteFile(path, []byte(input), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := EnsureCanonicalConfig(fs, path, ConfigState{ + IssuePrefix: "si", + CustomTypes: []string{"alpha", "gamma"}, + }); err != nil { + t.Fatalf("EnsureCanonicalConfig() error = %v", err) + } + + got := readConfigFile(t, path) + if _, err := readConfigDoc(fs, path); err != nil { + t.Fatalf("repaired config must parse as YAML, got %v\n%s", err, got) + } + value, ok := scanConfigLineValueFromData([]byte(got), "types.custom:") + if !ok { + t.Fatalf("types.custom missing after fallback repair:\n%s", got) + } + // Exact merged set: on-disk alpha,beta (unquoted) then missing baseline gamma. + // No stray quote-bearing tokens like `"alpha` or `beta"`. + if want := "alpha,beta,gamma"; value != want { + t.Fatalf("fallback types.custom = %q, want %q (quote corruption?)", value, want) + } + if strings.Contains(value, `"`) { + t.Fatalf("types.custom value retains quote characters: %q", value) + } +} + +func TestMergeCustomTypes(t *testing.T) { + tests := []struct { + name string + current []string + required []string + want []string + }{ + {"empty current", nil, []string{"a", "b"}, []string{"a", "b"}}, + {"empty required", []string{"a", "b"}, nil, []string{"a", "b"}}, + {"current first then missing", []string{"z", "a"}, []string{"a", "b"}, []string{"z", "a", "b"}}, + {"dedup and trim", []string{" a ", "a", ""}, []string{"a", "b"}, []string{"a", "b"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MergeCustomTypes(tt.current, tt.required) + if strings.Join(got, ",") != strings.Join(tt.want, ",") { + t.Fatalf("MergeCustomTypes(%v, %v) = %v, want %v", tt.current, tt.required, got, tt.want) + } + }) + } +} diff --git a/internal/doctor/checks_custom_types.go b/internal/doctor/checks_custom_types.go index 83159ac0e1..ffec6e24b7 100644 --- a/internal/doctor/checks_custom_types.go +++ b/internal/doctor/checks_custom_types.go @@ -11,6 +11,7 @@ import ( "time" "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/beads/contract" ) // RequiredCustomTypes lists the bead types that Gas City requires @@ -122,38 +123,10 @@ func (c *CustomTypesCheck) Fix(_ *CheckContext) error { if err != nil { return fmt.Errorf("reading current custom types: %w", err) } - merged := mergeCustomTypes(current, RequiredCustomTypes) + merged := contract.MergeCustomTypes(current, RequiredCustomTypes) return setCustomTypes(c.Dir, strings.Join(merged, ",")) } -// mergeCustomTypes returns the union of current and required, in order: -// current entries first (preserving user order), then any required entries -// not already present. Empty/whitespace-only entries are dropped and -// duplicates are removed. -func mergeCustomTypes(current, required []string) []string { - seen := make(map[string]bool, len(current)+len(required)) - merged := make([]string, 0, len(current)+len(required)) - for _, t := range current { - trimmed := strings.TrimSpace(t) - if trimmed == "" { - continue - } - if seen[trimmed] { - continue - } - seen[trimmed] = true - merged = append(merged, trimmed) - } - for _, req := range required { - if seen[req] { - continue - } - seen[req] = true - merged = append(merged, req) - } - return merged -} - // getCustomTypes reads the current types.custom config from a bd store. // Uses --json so an unset key returns an empty string value rather than // the human-readable "types.custom (not set)" sentinel (which would diff --git a/internal/doctor/checks_custom_types_test.go b/internal/doctor/checks_custom_types_test.go index f7597406c3..433a18297d 100644 --- a/internal/doctor/checks_custom_types_test.go +++ b/internal/doctor/checks_custom_types_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "reflect" "testing" + + "github.com/gastownhall/gascity/internal/beads/contract" ) func TestCustomTypesCheck_NoBeadsDir(t *testing.T) { @@ -139,9 +141,9 @@ func TestMergeCustomTypes(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := mergeCustomTypes(tc.current, tc.required) + got := contract.MergeCustomTypes(tc.current, tc.required) if !reflect.DeepEqual(got, tc.want) { - t.Errorf("mergeCustomTypes(%v, %v) = %v, want %v", + t.Errorf("contract.MergeCustomTypes(%v, %v) = %v, want %v", tc.current, tc.required, got, tc.want) } }) From 024898f3e3243c0324d7165bf479fdec074d4adc Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Fri, 10 Jul 2026 09:55:23 -0700 Subject: [PATCH 045/225] feat(sling): dashboard deep link to the created run (CLI + JSON + API parity + warming grace) (#4127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary After a successful `gc sling`, surface a dashboard link to the work that was just dispatched — on the human output (`Dashboard: `), in `--json` (`dashboard_url`, contract schema updated), and with parity on `POST /v0/city/{cityName}/sling` (`slingResponse.dashboard_url`, OpenAPI + generated Go client regenerated). **Link policy:** a single graph.v2 workflow launch deep-links to `/city/{registryName}/runs/{workflowID}`; every other successful shape (wisps, plain beads, batches, idempotent skips) links to the runs list with expectation-setting copy (`new work can take a minute or two to appear`); dry-run and failures mint no link; any resolution failure degrades silently — the link is a convenience and never fails or noticeably slows the sling. **Resolution is supervisor-only:** supervisor liveness (500ms shared deadline) → loopback base URL → supervisor registry `EffectiveName()` (the dashboard routes by registry name, not config city name) → BFF city-name grammar → `GET /api/health` probe (1s; the endpoint exists only when the dashboard is mounted, so the SPA-less standalone `[api]` port can never mint dead links). ## Warming grace (both halves) A run slung from the CLI is invisible to the dashboard's run projection until the controller's cache-reconcile emits its bead events (30–120s), and the SPA treated 404 as terminal — so a freshly printed deep link used to fail for every immediate click. - **Server:** `runproj` exposes an `ErrRunNotFound` sentinel; both run-detail endpoints (JSON GET + SSE precheck, now sharing one error writer) answer a truly-unknown runId with the retryable warming 503 for a 180s first-seen grace window — `Retry-After: 5`, body reason `unknown_run` so clients can tell it from cold-replay warming. 422 `not_run_view` is checked first and never graced. The tracker is bounded (1024 entries, 128-byte runId cap) and clock-injectable. - **Client:** the SPA polls warming 503s (fast delays, then 5s cadence, 180s budget matching the server window), renders honest "this run may still be being recorded — or may no longer exist" copy while polling, and its event-nudge recovery now anchors on the route's runId so eventual bead events recover even the failed state. The `go:embed`-ed `dist/` bundle is regenerated in the same commit (no CI drift gate exists for it). ## Red-team hardening Built by subagent workflow, then red-teamed (5 reviewers × 3 adversarial refuters per finding). Confirmed findings, all fixed: - **P0:** SPA retry budget (~4.2s) could not span the 30–120s event gap the server grace was sized for → client-half fix above. - **P1 (unanimous):** grace map capped entries, not bytes — unauthenticated `/api` callers could pin ~1GiB/city via oversized runIds → 128-byte cap before tracking. - **P1 (unanimous):** a check-reordering mutant survived the suite → `TestRunDetailWarmingDoesNotStartGraceClock` pins that warming-phase requests neither start nor consume the grace clock (kill verified by re-applying the mutant). - **P2 (×3 reviewers):** wildcard-bind supervisors minted `dashboard_url` pointing remote `/v0` callers at their **own** loopback → wildcard binds (`0.0.0.0`/`::`/`[::]`) now omit the link base entirely (no Host-header derivation — spoofable). Follow-up for remote/hosted topologies filed as a bd task. - **P2 (unanimous):** `supervisorAlive()`'s ~3s-per-socket budget could stall every successful sling on a wedged supervisor → whole resolution deadline-bounded (~1.5s worst case). ## Testing - TDD throughout; new coverage: link-path helpers/grammar, grace-window semantics (first-seen, cap, forget-on-resolve, ordering, oversized IDs), CLI resolver chain via hook seams (down/unregistered/invalid-name/health-fail/wedged-liveness), human + JSON output shapes, schema validation, API handler presence/absence, wildcard-bind omission, SPA warming poll (fake timers). - Gates: `go vet ./...`, full `make test` (green; one pre-existing `internal/eventfeed` flake proven unrelated — no dep path, passes 3/3 isolated, full re-run green), `make dashboard-check`, `TestOpenAPISpecInSync` + generated-client guard, wire-contract cross-check (BFF ↔ SPA ↔ CLI). Tracked as bd `ga-6k7ov5`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 --- cmd/gc/cmd_sling.go | 21 +- cmd/gc/sling_dashboard_link.go | 111 ++++ cmd/gc/sling_dashboard_link_test.go | 518 ++++++++++++++++++ cmd/gc/supervisor_dashboard.go | 29 + cmd/gc/supervisor_dashboard_test.go | 63 +++ docs/reference/schema/openapi.json | 4 + docs/reference/schema/openapi.txt | 4 + internal/api/dashboardbff/enrichment_cache.go | 5 +- internal/api/dashboardbff/links.go | 48 ++ internal/api/dashboardbff/links_test.go | 61 +++ internal/api/dashboardbff/plane.go | 2 +- internal/api/dashboardbff/rundetail_grace.go | 112 ++++ .../api/dashboardbff/rundetail_grace_test.go | 380 +++++++++++++ internal/api/dashboardbff/rundetail_stream.go | 29 +- .../api/dashboardbff/rundetail_stream_test.go | 21 +- .../api/dashboardbff/rundetailtailer_test.go | 24 +- internal/api/dashboardbff/runtailer.go | 120 +++- internal/api/dashboardbff/util.go | 12 - ...ivity-C0ndMSgp.js => Activity-DTboxwTI.js} | 2 +- ...il-4AW6d3TF.js => AgentDetail-DVT9Be-a.js} | 2 +- ...{Agents-sZ3Kn-9C.js => Agents-CF9gHKR0.js} | 2 +- ...me-QKhI8-ES.js => AmbientHome-usE4zKNv.js} | 2 +- ...KOlUSQL.js => BeadDetailModal-BtVrX_Fu.js} | 2 +- .../{Beads-CRhPo2Gt.js => Beads-DJjixOgD.js} | 2 +- .../{Field-BpdGqWpv.js => Field-Dsl4x4KL.js} | 2 +- .../dist/assets/FormulaRunDetail-BIoITriX.js | 12 - .../dist/assets/FormulaRunDetail-CFys0Xia.js | 12 + ...{Health-DWOkvU0J.js => Health-C5mLLJQ2.js} | 2 +- ...jv3DcC3.js => LiveSessionPeek-jm19JJ4Z.js} | 2 +- .../{Mail-CfeMOQZF.js => Mail-767k9Nkh.js} | 2 +- ...der-5RHLpIfH.js => PageHeader-D_D-jYn1.js} | 2 +- .../{Runs-DlWanzbB.js => Runs-BCTFHOlQ.js} | 2 +- ...r-DGo-aCtn.js => SseIndicator-CeTTAF2S.js} | 2 +- ...er-BIFUAoAh.js => StageLadder-B3yZa5o4.js} | 2 +- .../{Table-3q0HSJQI.js => Table-DojZJIvD.js} | 2 +- ...ads-DcDPDlRM.js => agentReads-C0EYRgYm.js} | 2 +- ...ants-DOaI3lZl.js => constants-DBKWGg29.js} | 2 +- .../{index-BFDP6Xwd.js => index-C20tCZFz.js} | 4 +- ...ctOf-CJPpTC86.js => projectOf-CwPPScnJ.js} | 2 +- ...CE9qAvrH.js => useListFilters-C0Eq1DLc.js} | 2 +- ...6CPUo.js => useVisibleRefresh-D_HCcAAw.js} | 2 +- internal/api/dashboardspa/dist/index.html | 2 +- .../src/hooks/useFormulaRunDetail.test.tsx | 93 +++- .../frontend/src/hooks/useFormulaRunDetail.ts | 63 ++- .../src/routes/FormulaRunDetail.test.tsx | 104 +++- .../frontend/src/routes/FormulaRunDetail.tsx | 24 +- .../frontend/src/supervisor/runDetail.test.ts | 100 +++- .../web/frontend/src/supervisor/runDetail.ts | 103 +++- internal/api/genclient/client_gen.go | 21 +- internal/api/handler_sling.go | 1 + internal/api/huma_handlers_sling.go | 38 ++ .../api/huma_handlers_sling_dashboard_test.go | 296 ++++++++++ internal/api/openapi.json | 4 + internal/api/server.go | 8 + internal/api/supervisor.go | 36 ++ internal/runproj/detail.go | 13 +- internal/runproj/detail_snapshot_test.go | 16 + schemas/sling/result.schema.json | 4 + 58 files changed, 2368 insertions(+), 190 deletions(-) create mode 100644 cmd/gc/sling_dashboard_link.go create mode 100644 cmd/gc/sling_dashboard_link_test.go create mode 100644 internal/api/dashboardbff/links.go create mode 100644 internal/api/dashboardbff/links_test.go create mode 100644 internal/api/dashboardbff/rundetail_grace.go create mode 100644 internal/api/dashboardbff/rundetail_grace_test.go rename internal/api/dashboardspa/dist/assets/{Activity-C0ndMSgp.js => Activity-DTboxwTI.js} (98%) rename internal/api/dashboardspa/dist/assets/{AgentDetail-4AW6d3TF.js => AgentDetail-DVT9Be-a.js} (96%) rename internal/api/dashboardspa/dist/assets/{Agents-sZ3Kn-9C.js => Agents-CF9gHKR0.js} (97%) rename internal/api/dashboardspa/dist/assets/{AmbientHome-QKhI8-ES.js => AmbientHome-usE4zKNv.js} (98%) rename internal/api/dashboardspa/dist/assets/{BeadDetailModal-BKOlUSQL.js => BeadDetailModal-BtVrX_Fu.js} (99%) rename internal/api/dashboardspa/dist/assets/{Beads-CRhPo2Gt.js => Beads-DJjixOgD.js} (97%) rename internal/api/dashboardspa/dist/assets/{Field-BpdGqWpv.js => Field-Dsl4x4KL.js} (85%) delete mode 100644 internal/api/dashboardspa/dist/assets/FormulaRunDetail-BIoITriX.js create mode 100644 internal/api/dashboardspa/dist/assets/FormulaRunDetail-CFys0Xia.js rename internal/api/dashboardspa/dist/assets/{Health-DWOkvU0J.js => Health-C5mLLJQ2.js} (98%) rename internal/api/dashboardspa/dist/assets/{LiveSessionPeek-Cjv3DcC3.js => LiveSessionPeek-jm19JJ4Z.js} (99%) rename internal/api/dashboardspa/dist/assets/{Mail-CfeMOQZF.js => Mail-767k9Nkh.js} (98%) rename internal/api/dashboardspa/dist/assets/{PageHeader-5RHLpIfH.js => PageHeader-D_D-jYn1.js} (89%) rename internal/api/dashboardspa/dist/assets/{Runs-DlWanzbB.js => Runs-BCTFHOlQ.js} (98%) rename internal/api/dashboardspa/dist/assets/{SseIndicator-DGo-aCtn.js => SseIndicator-CeTTAF2S.js} (88%) rename internal/api/dashboardspa/dist/assets/{StageLadder-BIFUAoAh.js => StageLadder-B3yZa5o4.js} (91%) rename internal/api/dashboardspa/dist/assets/{Table-3q0HSJQI.js => Table-DojZJIvD.js} (96%) rename internal/api/dashboardspa/dist/assets/{agentReads-DcDPDlRM.js => agentReads-C0EYRgYm.js} (80%) rename internal/api/dashboardspa/dist/assets/{constants-DOaI3lZl.js => constants-DBKWGg29.js} (95%) rename internal/api/dashboardspa/dist/assets/{index-BFDP6Xwd.js => index-C20tCZFz.js} (99%) rename internal/api/dashboardspa/dist/assets/{projectOf-CJPpTC86.js => projectOf-CwPPScnJ.js} (97%) rename internal/api/dashboardspa/dist/assets/{useListFilters-CE9qAvrH.js => useListFilters-C0Eq1DLc.js} (98%) rename internal/api/dashboardspa/dist/assets/{useVisibleRefresh-Bxd6CPUo.js => useVisibleRefresh-D_HCcAAw.js} (92%) create mode 100644 internal/api/huma_handlers_sling_dashboard_test.go diff --git a/cmd/gc/cmd_sling.go b/cmd/gc/cmd_sling.go index 526195d31c..c1c8084a82 100644 --- a/cmd/gc/cmd_sling.go +++ b/cmd/gc/cmd_sling.go @@ -927,7 +927,7 @@ func doSlingBatchWithJSON(opts slingOpts, deps slingDeps, querier BeadChildQueri } if result.DryRun { if jsonOutput { - return writeSlingJSONResult(result, jsonStdout, stderr) + return writeSlingJSONResult(result, "", jsonStdout, stderr) } // For batch dry-run, look up the container bead for display. // DoSling sets ContainerType on the result only when it actually @@ -954,8 +954,21 @@ func doSlingBatchWithJSON(opts slingOpts, deps slingDeps, querier BeadChildQueri if result.NudgeAgent != nil { doSlingNudge(result.NudgeAgent, deps.CityName, deps.CityPath, deps.Cfg, deps.SP, deps.Store, humanStdout, stderr) } + // Success only (never dry-run or error): surface a dashboard deep link + // when one resolves. Resolution failure degrades silently to no link. + dashboardURL, dashboardRunsList := slingDashboardURLHook(deps.CityPath, result) if jsonOutput { - return writeSlingJSONResult(result, jsonStdout, stderr) + return writeSlingJSONResult(result, dashboardURL, jsonStdout, stderr) + } + if dashboardURL != "" { + // Runs-list landings lag the dashboard's cache-reconcile cycle by + // up to a couple of minutes, so set that expectation inline; + // run-detail links render immediately and stay bare. + suffix := "" + if dashboardRunsList { + suffix = " (new work can take a minute or two to appear)" + } + fmt.Fprintf(humanStdout, "Dashboard: %s%s\n", dashboardURL, suffix) //nolint:errcheck // best-effort stdout } return 0 } @@ -982,6 +995,7 @@ type slingJSONResult struct { Routed bool `json:"routed"` Queued bool `json:"queued"` DryRun bool `json:"dry_run"` + DashboardURL string `json:"dashboard_url,omitempty"` Warnings []string `json:"warnings,omitempty"` Batch *slingJSONBatchSummary `json:"batch,omitempty"` } @@ -995,8 +1009,9 @@ type slingJSONBatchSummary struct { Idempotent int `json:"idempotent"` } -func writeSlingJSONResult(result sling.SlingResult, stdout, stderr io.Writer) int { +func writeSlingJSONResult(result sling.SlingResult, dashboardURL string, stdout, stderr io.Writer) int { payload := slingJSONFromResult(result) + payload.DashboardURL = dashboardURL if err := writeCLIJSONLine(stdout, payload); err != nil { fmt.Fprintf(stderr, "gc sling: %v\n", err) //nolint:errcheck // best-effort stderr return 1 diff --git a/cmd/gc/sling_dashboard_link.go b/cmd/gc/sling_dashboard_link.go new file mode 100644 index 0000000000..651292cc0e --- /dev/null +++ b/cmd/gc/sling_dashboard_link.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/gastownhall/gascity/internal/api/dashboardbff" + "github.com/gastownhall/gascity/internal/sling" +) + +// Link resolution runs inline after a successful sling, so every network +// step is deadline-bounded: the supervisor liveness ping gets +// slingDashboardLivenessTimeout (shared across all socket candidates) and +// the dashboard health probe gets slingDashboardHealthTimeout. Worst case a +// wedged supervisor delays the sling output by ~1.5s total (0.5s liveness + +// 1s probe); the remaining steps are local file reads. +const ( + slingDashboardLivenessTimeout = 500 * time.Millisecond + slingDashboardHealthTimeout = time.Second +) + +// slingDashboardURLHook resolves the dashboard deep link surfaced after a +// successful sling. Package var so tests can stub the whole chain. +var slingDashboardURLHook = slingDashboardURL + +// slingSupervisorAliveHook probes supervisor liveness under a deadline. +// Package var so resolver tests can fake liveness without a control socket. +var slingSupervisorAliveHook = slingSupervisorAliveUntil + +// dashboardHealthOKHook probes the dashboard /api plane. Package var so +// resolver tests can fake the probe without a live supervisor. +var dashboardHealthOKHook = dashboardHealthOK + +// slingDashboardURL returns the absolute dashboard URL for a successful +// sling result, or "" when no live link can be minted, plus whether the +// link lands on the runs list (so callers can warn that list landings lag +// cache-reconcile) rather than a run's detail view. It never returns an +// error: any resolution failure degrades silently to no link, because the +// link is a convenience and must not fail or slow the sling itself. +// +// The dashboard SPA is served only by the supervisor listener (same-origin +// with the /api BFF plane), so resolution is supervisor-only — the +// standalone controller's [api] port serves /v0 without the SPA and would +// mint dead links. The chain: supervisor alive → supervisor base URL → +// city registered with the supervisor (the SPA routes by registry name, +// not config city name) → name passes the BFF grammar → dashboard actually +// mounted (GET /api/health) → deep link. A single result carrying a +// graph.v2 workflow root links straight to that run's detail view; every +// other successful shape (wisps, plain beads, batches, idempotent skips) +// links to the runs list, since only graph.v2 roots render run detail. +func slingDashboardURL(cityPath string, result sling.SlingResult) (url string, runsList bool) { + if slingSupervisorAliveHook(time.Now().Add(slingDashboardLivenessTimeout)) == 0 { + return "", false + } + baseURL, err := supervisorAPIBaseURLHook() + if err != nil { + return "", false + } + baseURL = strings.TrimRight(baseURL, "/") + entry, registered, err := registeredCityEntry(cityPath) + if err != nil || !registered { + return "", false + } + name := entry.EffectiveName() + if !dashboardbff.ValidCityName(name) { + return "", false + } + if !dashboardHealthOKHook(baseURL) { + return "", false + } + if result.WorkflowID != "" && len(result.Children) == 0 && result.ContainerType == "" { + return baseURL + dashboardbff.RunDetailPath(name, result.WorkflowID), false + } + return baseURL + dashboardbff.RunsListPath(name), true +} + +// slingSupervisorAliveUntil reports the running supervisor's PID by pinging +// each control-socket candidate under one shared deadline, or 0 when none +// answers in time. It is the deadline-bounded sibling of supervisorAlive, +// whose ~3s-per-socket default budget is too slow for the post-sling path: +// here a wedged socket must cost at most the caller's budget, never the +// sling. +func slingSupervisorAliveUntil(deadline time.Time) int { + for _, sockPath := range supervisorSocketPathCandidates() { + if pid := supervisorAliveAtPathUntil(sockPath, deadline); pid != 0 { + return pid + } + } + return 0 +} + +// dashboardHealthOK reports whether the dashboard /api plane is mounted at +// baseURL by probing its unauthenticated GET /api/health endpoint. The +// endpoint exists only when the dashboard is mounted, so anything but a +// fast 200 means no link should be emitted. +func dashboardHealthOK(baseURL string) bool { + ctx, cancel := context.WithTimeout(context.Background(), slingDashboardHealthTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/health", nil) + if err != nil { + return false + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() //nolint:errcheck // read-only probe + return resp.StatusCode == http.StatusOK +} diff --git a/cmd/gc/sling_dashboard_link_test.go b/cmd/gc/sling_dashboard_link_test.go new file mode 100644 index 0000000000..51653a0e7f --- /dev/null +++ b/cmd/gc/sling_dashboard_link_test.go @@ -0,0 +1,518 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/runtime" + "github.com/gastownhall/gascity/internal/sling" + "github.com/gastownhall/gascity/internal/supervisor" +) + +// stubSlingDashboardSupervisor fakes supervisor liveness and base-URL +// discovery for the resolver tests. Restored on cleanup. +func stubSlingDashboardSupervisor(t *testing.T, alivePID int, baseURL string, baseErr error) { + t.Helper() + oldAlive := slingSupervisorAliveHook + oldBase := supervisorAPIBaseURLHook + t.Cleanup(func() { + slingSupervisorAliveHook = oldAlive + supervisorAPIBaseURLHook = oldBase + }) + slingSupervisorAliveHook = func(time.Time) int { return alivePID } + supervisorAPIBaseURLHook = func() (string, error) { return baseURL, baseErr } +} + +// registerSlingDashboardCity points GC_HOME at a temp registry and registers +// a city under the given supervisor name, returning the city path. +func registerSlingDashboardCity(t *testing.T, name string) string { + t.Helper() + t.Setenv("GC_HOME", t.TempDir()) + cityPath := filepath.Join(t.TempDir(), "city") + if err := os.MkdirAll(cityPath, 0o755); err != nil { + t.Fatal(err) + } + reg := supervisor.NewRegistry(supervisor.RegistryPath()) + if err := reg.Register(cityPath, name); err != nil { + t.Fatal(err) + } + return cityPath +} + +// slingDashboardHealthServer serves GET /api/health with the given status. +func slingDashboardHealthServer(t *testing.T, status int) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/health" { + http.NotFound(w, r) + return + } + w.WriteHeader(status) + if status == http.StatusOK { + w.Write([]byte(`{"ok":true}`)) //nolint:errcheck + } + })) + t.Cleanup(srv.Close) + return srv +} + +func TestSlingDashboardURLWorkflowRunDetail(t *testing.T) { + cityPath := registerSlingDashboardCity(t, "bright-lights") + srv := slingDashboardHealthServer(t, http.StatusOK) + stubSlingDashboardSupervisor(t, 4242, srv.URL, nil) + + got, runsList := slingDashboardURL(cityPath, sling.SlingResult{WorkflowID: "gcg-run-1", BeadID: "gcg-run-1"}) + want := srv.URL + "/city/bright-lights/runs/gcg-run-1" + if got != want { + t.Fatalf("slingDashboardURL = %q, want %q", got, want) + } + if runsList { + t.Fatal("slingDashboardURL runsList = true, want false for run detail") + } +} + +func TestSlingDashboardURLRunsListVariants(t *testing.T) { + cityPath := registerSlingDashboardCity(t, "bright-lights") + srv := slingDashboardHealthServer(t, http.StatusOK) + stubSlingDashboardSupervisor(t, 4242, srv.URL, nil) + + want := srv.URL + "/city/bright-lights/runs" + tests := []struct { + name string + result sling.SlingResult + }{ + {"wisp", sling.SlingResult{BeadID: "b-1", WispRootID: "w-1"}}, + {"plain bead", sling.SlingResult{BeadID: "b-1"}}, + {"idempotent skip", sling.SlingResult{BeadID: "b-1", Idempotent: true}}, + {"batch", sling.SlingResult{ + BeadID: "convoy-1", ContainerType: "convoy", Total: 2, Routed: 2, + Children: []sling.SlingChildResult{ + {BeadID: "c-1", Routed: true, WorkflowID: "gcg-c1"}, + {BeadID: "c-2", Routed: true, WorkflowID: "gcg-c2"}, + }, + }}, + {"batch with top-level workflow id", sling.SlingResult{ + BeadID: "convoy-1", WorkflowID: "gcg-c1", ContainerType: "convoy", Total: 1, Routed: 1, + Children: []sling.SlingChildResult{{BeadID: "c-1", Routed: true, WorkflowID: "gcg-c1"}}, + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, runsList := slingDashboardURL(cityPath, tt.result) + if got != want { + t.Fatalf("slingDashboardURL = %q, want %q", got, want) + } + if !runsList { + t.Fatal("slingDashboardURL runsList = false, want true for runs list") + } + }) + } +} + +func TestSlingDashboardURLSuppressed(t *testing.T) { + workflowResult := sling.SlingResult{WorkflowID: "gcg-run-1", BeadID: "gcg-run-1"} + + t.Run("supervisor down", func(t *testing.T) { + cityPath := registerSlingDashboardCity(t, "bright-lights") + srv := slingDashboardHealthServer(t, http.StatusOK) + stubSlingDashboardSupervisor(t, 0, srv.URL, nil) + if got, _ := slingDashboardURL(cityPath, workflowResult); got != "" { + t.Fatalf("slingDashboardURL = %q, want empty when supervisor is down", got) + } + }) + + t.Run("base url error", func(t *testing.T) { + cityPath := registerSlingDashboardCity(t, "bright-lights") + stubSlingDashboardSupervisor(t, 4242, "", fmt.Errorf("no supervisor config")) + if got, _ := slingDashboardURL(cityPath, workflowResult); got != "" { + t.Fatalf("slingDashboardURL = %q, want empty on base URL failure", got) + } + }) + + t.Run("city unregistered", func(t *testing.T) { + registerSlingDashboardCity(t, "bright-lights") + srv := slingDashboardHealthServer(t, http.StatusOK) + stubSlingDashboardSupervisor(t, 4242, srv.URL, nil) + other := filepath.Join(t.TempDir(), "other-city") + if err := os.MkdirAll(other, 0o755); err != nil { + t.Fatal(err) + } + if got, _ := slingDashboardURL(other, workflowResult); got != "" { + t.Fatalf("slingDashboardURL = %q, want empty for unregistered city", got) + } + }) + + t.Run("dashboard-invalid city name", func(t *testing.T) { + // Valid per the supervisor registry grammar (dots allowed) but + // invalid per the stricter BFF grammar — dashboard-unreachable. + cityPath := registerSlingDashboardCity(t, "bright.lights") + srv := slingDashboardHealthServer(t, http.StatusOK) + stubSlingDashboardSupervisor(t, 4242, srv.URL, nil) + if got, _ := slingDashboardURL(cityPath, workflowResult); got != "" { + t.Fatalf("slingDashboardURL = %q, want empty for BFF-invalid name", got) + } + }) + + t.Run("health probe non-200", func(t *testing.T) { + cityPath := registerSlingDashboardCity(t, "bright-lights") + srv := slingDashboardHealthServer(t, http.StatusNotFound) + stubSlingDashboardSupervisor(t, 4242, srv.URL, nil) + if got, _ := slingDashboardURL(cityPath, workflowResult); got != "" { + t.Fatalf("slingDashboardURL = %q, want empty when dashboard is not mounted", got) + } + }) + + t.Run("health probe unreachable", func(t *testing.T) { + cityPath := registerSlingDashboardCity(t, "bright-lights") + srv := httptest.NewServer(http.NotFoundHandler()) + base := srv.URL + srv.Close() + stubSlingDashboardSupervisor(t, 4242, base, nil) + if got, _ := slingDashboardURL(cityPath, workflowResult); got != "" { + t.Fatalf("slingDashboardURL = %q, want empty when probe cannot connect", got) + } + }) +} + +func TestSlingDashboardURLWedgedLivenessBounded(t *testing.T) { + cityPath := registerSlingDashboardCity(t, "bright-lights") + srv := slingDashboardHealthServer(t, http.StatusOK) + stubSlingDashboardSupervisor(t, 4242, srv.URL, nil) + + // Simulate a fully wedged control socket: the liveness probe returns + // only when the caller's deadline expires. The resolver must hand it a + // tight budget so a hung supervisor cannot stall a successful sling. + var budget time.Duration + slingSupervisorAliveHook = func(deadline time.Time) int { + budget = time.Until(deadline) + time.Sleep(time.Until(deadline)) + return 0 + } + + start := time.Now() + got, _ := slingDashboardURL(cityPath, sling.SlingResult{WorkflowID: "gcg-run-1", BeadID: "gcg-run-1"}) + elapsed := time.Since(start) + + if got != "" { + t.Fatalf("slingDashboardURL = %q, want empty when liveness times out", got) + } + if budget > slingDashboardLivenessTimeout { + t.Fatalf("liveness budget = %v, want <= %v", budget, slingDashboardLivenessTimeout) + } + // Generous CI-safe bound on the ~500ms budget. + if elapsed >= 3*time.Second { + t.Fatalf("resolver took %v with a wedged liveness probe, want well under 3s", elapsed) + } +} + +func TestSlingSupervisorAliveUntil(t *testing.T) { + t.Run("hung socket bounded by deadline", func(t *testing.T) { + // shortTempDir keeps the socket path under the unix sun_path limit. + t.Setenv("GC_HOME", shortTempDir(t, "gc-home-")) + sockPath := supervisorSocketPathCandidates()[0] + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) //nolint:errcheck + // Accept connections but never answer the ping, like a wedged + // supervisor whose control loop has stalled. + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() //nolint:errcheck + } + }() + + start := time.Now() + pid := slingSupervisorAliveUntil(time.Now().Add(200 * time.Millisecond)) + elapsed := time.Since(start) + + if pid != 0 { + t.Fatalf("slingSupervisorAliveUntil = %d, want 0 for a hung socket", pid) + } + if elapsed >= 3*time.Second { + t.Fatalf("probe took %v against a hung socket, want bounded by the deadline", elapsed) + } + }) + + t.Run("expired deadline", func(t *testing.T) { + t.Setenv("GC_HOME", t.TempDir()) + if pid := slingSupervisorAliveUntil(time.Now().Add(-time.Second)); pid != 0 { + t.Fatalf("slingSupervisorAliveUntil = %d, want 0 for an expired deadline", pid) + } + }) +} + +func TestDashboardHealthOK(t *testing.T) { + t.Run("200", func(t *testing.T) { + srv := slingDashboardHealthServer(t, http.StatusOK) + if !dashboardHealthOK(srv.URL) { + t.Fatal("dashboardHealthOK = false, want true for 200") + } + }) + t.Run("500", func(t *testing.T) { + srv := slingDashboardHealthServer(t, http.StatusInternalServerError) + if dashboardHealthOK(srv.URL) { + t.Fatal("dashboardHealthOK = true, want false for 500") + } + }) + t.Run("connection refused", func(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + base := srv.URL + srv.Close() + if dashboardHealthOK(base) { + t.Fatal("dashboardHealthOK = true, want false for closed server") + } + }) +} + +// stubSlingDashboardLink replaces the wiring hook and records the city path +// it was invoked with. +func stubSlingDashboardLink(t *testing.T, url string, runsList bool) *string { + t.Helper() + old := slingDashboardURLHook + t.Cleanup(func() { slingDashboardURLHook = old }) + var gotCityPath string + slingDashboardURLHook = func(cityPath string, _ sling.SlingResult) (string, bool) { + gotCityPath = cityPath + return url, runsList + } + return &gotCityPath +} + +func TestDoSlingBatchPrintsDashboardLine(t *testing.T) { + link := "http://127.0.0.1:8372/city/test-city/runs" + gotCityPath := stubSlingDashboardLink(t, link, true) + + runner := newFakeRunner() + sp := runtime.NewFake() + cfg := &config.City{Workspace: config.Workspace{Name: "test-city"}} + a := config.Agent{Name: "mayor", MaxActiveSessions: intPtr(1)} + + deps, stdout, stderr := testDeps(cfg, sp, runner.run) + opts := testOpts(a, "BL-42") + code := doSlingBatch(opts, deps, nil, stdout, stderr) + + if code != 0 { + t.Fatalf("doSlingBatch returned %d, want 0; stderr: %s", code, stderr.String()) + } + out := stdout.String() + slungIdx := strings.Index(out, "Slung BL-42") + // Runs-list landings lag cache-reconcile, so the human line sets that + // expectation inline. + dashIdx := strings.Index(out, "Dashboard: "+link+" (new work can take a minute or two to appear)") + if slungIdx == -1 || dashIdx == -1 { + t.Fatalf("stdout = %q, want sling confirmation followed by suffixed runs-list dashboard line", out) + } + if dashIdx < slungIdx { + t.Fatalf("stdout = %q, want dashboard line after confirmation", out) + } + if *gotCityPath != deps.CityPath { + t.Fatalf("hook city path = %q, want %q", *gotCityPath, deps.CityPath) + } +} + +func TestDoSlingBatchPrintsBareDashboardLineForRunDetail(t *testing.T) { + link := "http://127.0.0.1:8372/city/test-city/runs/gcg-run-1" + stubSlingDashboardLink(t, link, false) + + runner := newFakeRunner() + sp := runtime.NewFake() + cfg := &config.City{Workspace: config.Workspace{Name: "test-city"}} + a := config.Agent{Name: "mayor", MaxActiveSessions: intPtr(1)} + + deps, stdout, stderr := testDeps(cfg, sp, runner.run) + opts := testOpts(a, "BL-42") + code := doSlingBatch(opts, deps, nil, stdout, stderr) + + if code != 0 { + t.Fatalf("doSlingBatch returned %d, want 0; stderr: %s", code, stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, "Dashboard: "+link+"\n") { + t.Fatalf("stdout = %q, want bare dashboard line for run detail", out) + } + if strings.Contains(out, "(new work can take a minute or two to appear)") { + t.Fatalf("stdout = %q, want no runs-list suffix on a run-detail link", out) + } +} + +func TestDoSlingBatchOmitsDashboardLineWhenUnresolved(t *testing.T) { + stubSlingDashboardLink(t, "", false) + + runner := newFakeRunner() + sp := runtime.NewFake() + cfg := &config.City{Workspace: config.Workspace{Name: "test-city"}} + a := config.Agent{Name: "mayor", MaxActiveSessions: intPtr(1)} + + deps, stdout, stderr := testDeps(cfg, sp, runner.run) + opts := testOpts(a, "BL-42") + code := doSlingBatch(opts, deps, nil, stdout, stderr) + + if code != 0 { + t.Fatalf("doSlingBatch returned %d, want 0; stderr: %s", code, stderr.String()) + } + if strings.Contains(stdout.String(), "Dashboard:") { + t.Fatalf("stdout = %q, want no dashboard line when resolution fails", stdout.String()) + } +} + +func TestDoSlingBatchSkipsDashboardLinkOnDryRun(t *testing.T) { + old := slingDashboardURLHook + t.Cleanup(func() { slingDashboardURLHook = old }) + called := false + slingDashboardURLHook = func(string, sling.SlingResult) (string, bool) { + called = true + return "http://127.0.0.1:8372/city/test-city/runs", true + } + + runner := newFakeRunner() + sp := runtime.NewFake() + cfg := &config.City{Workspace: config.Workspace{Name: "test-city"}} + a := config.Agent{Name: "mayor", MaxActiveSessions: intPtr(1)} + + var stdout, stderr bytes.Buffer + deps, _, _ := testDeps(cfg, sp, runner.run) + deps.Store = seededStore("BL-42") + opts := testOpts(a, "BL-42") + opts.DryRun = true + code := doSlingBatchWithJSON(opts, deps, nil, true, io.Discard, &stdout, &stderr) + + if code != 0 { + t.Fatalf("dry-run returned %d, want 0; stderr: %s", code, stderr.String()) + } + if called { + t.Fatal("slingDashboardURLHook called on dry-run, want skipped") + } + if strings.Contains(stdout.String(), "dashboard_url") { + t.Fatalf("dry-run JSON = %q, want no dashboard_url", stdout.String()) + } +} + +func TestDoSlingBatchSkipsDashboardLinkOnError(t *testing.T) { + old := slingDashboardURLHook + t.Cleanup(func() { slingDashboardURLHook = old }) + called := false + slingDashboardURLHook = func(string, sling.SlingResult) (string, bool) { + called = true + return "http://127.0.0.1:8372/city/test-city/runs", true + } + + runner := newFakeRunner() + sp := runtime.NewFake() + cfg := &config.City{Workspace: config.Workspace{Name: "test-city"}} + a := config.Agent{Name: "mayor", MaxActiveSessions: intPtr(1)} + + q := newFakeChildQuerier() + q.getErr = fmt.Errorf("bd not available") + + deps, stdout, stderr := testDeps(cfg, sp, runner.run) + opts := testOpts(a, "BL-42") + code := doSlingBatch(opts, deps, q, stdout, stderr) + + if code == 0 { + t.Fatalf("doSlingBatch returned 0, want failure; stdout: %s", stdout.String()) + } + if called { + t.Fatal("slingDashboardURLHook called on error, want skipped") + } + if strings.Contains(stdout.String(), "Dashboard:") { + t.Fatalf("stdout = %q, want no dashboard line on failure", stdout.String()) + } +} + +func TestDoSlingBatchJSONIncludesDashboardURL(t *testing.T) { + link := "http://127.0.0.1:8372/city/test-city/runs/gcg-run-1" + stubSlingDashboardLink(t, link, false) + + runner := newFakeRunner() + sp := runtime.NewFake() + cfg := &config.City{Workspace: config.Workspace{Name: "test-city"}} + a := config.Agent{Name: "mayor", MaxActiveSessions: intPtr(1)} + + var jsonStdout, stderr bytes.Buffer + deps, _, _ := testDeps(cfg, sp, runner.run) + opts := testOpts(a, "BL-42") + code := doSlingBatchWithJSON(opts, deps, nil, true, io.Discard, &jsonStdout, &stderr) + + if code != 0 { + t.Fatalf("doSlingBatchWithJSON returned %d, want 0; stderr: %s", code, stderr.String()) + } + var payload struct { + DashboardURL string `json:"dashboard_url"` + } + if err := json.Unmarshal(jsonStdout.Bytes(), &payload); err != nil { + t.Fatalf("parsing JSON output: %v\n%s", err, jsonStdout.String()) + } + if payload.DashboardURL != link { + t.Fatalf("dashboard_url = %q, want %q", payload.DashboardURL, link) + } + validateJSONAgainstResultSchema(t, []string{"sling"}, jsonStdout.Bytes()) +} + +func TestDoSlingBatchJSONRunsListURLStaysBare(t *testing.T) { + link := "http://127.0.0.1:8372/city/test-city/runs" + stubSlingDashboardLink(t, link, true) + + runner := newFakeRunner() + sp := runtime.NewFake() + cfg := &config.City{Workspace: config.Workspace{Name: "test-city"}} + a := config.Agent{Name: "mayor", MaxActiveSessions: intPtr(1)} + + var jsonStdout, stderr bytes.Buffer + deps, _, _ := testDeps(cfg, sp, runner.run) + opts := testOpts(a, "BL-42") + code := doSlingBatchWithJSON(opts, deps, nil, true, io.Discard, &jsonStdout, &stderr) + + if code != 0 { + t.Fatalf("doSlingBatchWithJSON returned %d, want 0; stderr: %s", code, stderr.String()) + } + var payload struct { + DashboardURL string `json:"dashboard_url"` + } + if err := json.Unmarshal(jsonStdout.Bytes(), &payload); err != nil { + t.Fatalf("parsing JSON output: %v\n%s", err, jsonStdout.String()) + } + // The runs-list latency suffix is human copy only; JSON stays a bare URL. + if payload.DashboardURL != link { + t.Fatalf("dashboard_url = %q, want bare %q", payload.DashboardURL, link) + } + validateJSONAgainstResultSchema(t, []string{"sling"}, jsonStdout.Bytes()) +} + +func TestDoSlingBatchJSONOmitsDashboardURLWhenUnresolved(t *testing.T) { + stubSlingDashboardLink(t, "", false) + + runner := newFakeRunner() + sp := runtime.NewFake() + cfg := &config.City{Workspace: config.Workspace{Name: "test-city"}} + a := config.Agent{Name: "mayor", MaxActiveSessions: intPtr(1)} + + var jsonStdout, stderr bytes.Buffer + deps, _, _ := testDeps(cfg, sp, runner.run) + opts := testOpts(a, "BL-42") + code := doSlingBatchWithJSON(opts, deps, nil, true, io.Discard, &jsonStdout, &stderr) + + if code != 0 { + t.Fatalf("doSlingBatchWithJSON returned %d, want 0; stderr: %s", code, stderr.String()) + } + if strings.Contains(jsonStdout.String(), "dashboard_url") { + t.Fatalf("JSON output = %q, want dashboard_url omitted", jsonStdout.String()) + } + validateJSONAgainstResultSchema(t, []string{"sling"}, jsonStdout.Bytes()) +} diff --git a/cmd/gc/supervisor_dashboard.go b/cmd/gc/supervisor_dashboard.go index 89693c42a5..5a1758029d 100644 --- a/cmd/gc/supervisor_dashboard.go +++ b/cmd/gc/supervisor_dashboard.go @@ -67,6 +67,19 @@ func attachDashboard(mux *api.SupervisorMux, resolver api.CityResolver, readOnly } plane := dashboardbff.New(dashboardDeps(resolver, readOnly, bind, port, mux.LoopbackTransport())) mux.WithAPIPlane(plane.Handler()).WithStaticHandler(spa) + // Install the listener's link base alongside the SPA so per-city handlers + // can mint dashboard deep links (the sling response's dashboard_url). + // Standalone controller processes never call attachDashboard, so their + // /v0 responses omit the link instead of pointing at a dead origin. + // Wildcard binds also skip the base: dashboardLoopbackBaseURL would yield + // a loopback literal that is browser-reachable only on the supervisor + // host, so a remote /v0 caller would receive a dashboard_url pointing at + // its own machine. Omitting the link is the decided degradation — do NOT + // derive a base from request Host headers, which are spoofable. + if !wildcardBind(bind) { + base := dashboardLoopbackBaseURL(bind, port) + mux.WithDashboardBase(func() string { return base }) + } return plane, nil } @@ -95,10 +108,26 @@ func dashboardDeps(resolver api.CityResolver, readOnly bool, bind string, port i } } +// wildcardBind reports whether bind is a wildcard listener address (every +// spelling dashboardLoopbackBaseURL normalizes as wildcard; the empty string +// is NOT one — it means the config default, which BindOrDefault resolves to +// loopback). Wildcard binds have no single browser-reachable origin, so +// attachDashboard skips the dashboard link base for them. +func wildcardBind(bind string) bool { + switch bind { + case "0.0.0.0", "::", "[::]": + return true + } + return false +} + // dashboardLoopbackBaseURL builds the base URL the host-side samplers use to // read the supervisor's own /v0 API in-process. The supervisor may bind a // wildcard or non-loopback address, but the self-read must always dial // loopback, so wildcard/localhost binds are normalized to a loopback literal. +// This is the samplers' self-read address, not necessarily a browser-reachable +// origin — for wildcard binds attachDashboard must not reuse it as the +// dashboard link base. func dashboardLoopbackBaseURL(bind string, port int) string { host := bind switch bind { diff --git a/cmd/gc/supervisor_dashboard_test.go b/cmd/gc/supervisor_dashboard_test.go index 169fdc4a81..9db75d5127 100644 --- a/cmd/gc/supervisor_dashboard_test.go +++ b/cmd/gc/supervisor_dashboard_test.go @@ -3,6 +3,7 @@ package main import ( "net/http" "testing" + "time" "github.com/gastownhall/gascity/internal/api" "github.com/gastownhall/gascity/internal/api/dashboardbff" @@ -146,6 +147,68 @@ func TestDashboardCityResolverCitiesEmpty(t *testing.T) { } } +// TestAttachDashboardInstallsDashboardBase guards the sling dashboard_url +// wiring: for loopback and explicit-host binds attachDashboard must install +// the listener's browser-reachable link base on the mux, or per-city handlers +// can never mint dashboard deep links even though the dashboard is served. +// For wildcard binds it must install NO base: the loopback literal the +// samplers dial is browser-reachable only on the supervisor host, so a remote +// /v0 sling caller would receive a dashboard_url pointing at its own machine. +// Wildcard responses omit dashboard_url instead (silent degradation). +func TestAttachDashboardInstallsDashboardBase(t *testing.T) { + cases := map[string]struct { + bind string + want string // "" means no link base installed + }{ + "loopback v4": {"127.0.0.1", "http://127.0.0.1:8372"}, + "empty bind default": {"", "http://127.0.0.1:8372"}, + "localhost": {"localhost", "http://127.0.0.1:8372"}, + "loopback v6": {"::1", "http://[::1]:8372"}, + "explicit lan": {"192.168.1.5", "http://192.168.1.5:8372"}, + "wildcard v4": {"0.0.0.0", ""}, + "wildcard v6": {"::", ""}, + "wildcard v6 bracket": {"[::]", ""}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Setenv("GC_SUPERVISOR_DASHBOARD", "") + mux := newTestSupervisorMuxForDashboard() + plane, err := attachDashboard(mux, fakeDashResolver{}, false, tc.bind, 8372) + if err != nil { + t.Fatalf("attachDashboard: %v", err) + } + if plane == nil { + t.Fatal("attachDashboard returned nil plane with dashboard enabled") + } + if got := mux.DashboardBaseURL(); got != tc.want { + t.Fatalf("DashboardBaseURL for bind %q = %q, want %q", tc.bind, got, tc.want) + } + }) + } +} + +// TestAttachDashboardDisabledLeavesNoDashboardBase pins the standalone shape: +// with the dashboard disabled the mux must report no link base, so sling +// responses omit dashboard_url instead of minting dead links. +func TestAttachDashboardDisabledLeavesNoDashboardBase(t *testing.T) { + t.Setenv("GC_SUPERVISOR_DASHBOARD", "0") + mux := newTestSupervisorMuxForDashboard() + plane, err := attachDashboard(mux, fakeDashResolver{}, false, "127.0.0.1", 8372) + if err != nil { + t.Fatalf("attachDashboard: %v", err) + } + if plane != nil { + t.Fatal("attachDashboard returned a plane with the dashboard disabled") + } + if got := mux.DashboardBaseURL(); got != "" { + t.Fatalf("DashboardBaseURL = %q, want empty when the dashboard is disabled", got) + } +} + +func newTestSupervisorMuxForDashboard() *api.SupervisorMux { + return api.NewSupervisorMux(fakeDashResolver{}, nil, false, "vtest", "btest", time.Now()) +} + func TestDashboardEnabledToggle(t *testing.T) { t.Setenv("GC_SUPERVISOR_DASHBOARD", "0") if dashboardEnabled() { diff --git a/docs/reference/schema/openapi.json b/docs/reference/schema/openapi.json index ddd26ef171..c907bdf6f5 100644 --- a/docs/reference/schema/openapi.json +++ b/docs/reference/schema/openapi.json @@ -7390,6 +7390,10 @@ "bead": { "type": "string" }, + "dashboard_url": { + "description": "Absolute dashboard deep link for the slung work: the run detail view when a graph workflow was launched, otherwise the runs list. Present only when the serving process also hosts the dashboard (the supervisor listener); the standalone controller API omits it.", + "type": "string" + }, "formula": { "type": "string" }, diff --git a/docs/reference/schema/openapi.txt b/docs/reference/schema/openapi.txt index ddd26ef171..c907bdf6f5 100644 --- a/docs/reference/schema/openapi.txt +++ b/docs/reference/schema/openapi.txt @@ -7390,6 +7390,10 @@ "bead": { "type": "string" }, + "dashboard_url": { + "description": "Absolute dashboard deep link for the slung work: the run detail view when a graph workflow was launched, otherwise the runs list. Present only when the serving process also hosts the dashboard (the supervisor listener); the standalone controller API omits it.", + "type": "string" + }, "formula": { "type": "string" }, diff --git a/internal/api/dashboardbff/enrichment_cache.go b/internal/api/dashboardbff/enrichment_cache.go index b4257c9ea4..48611bae8d 100644 --- a/internal/api/dashboardbff/enrichment_cache.go +++ b/internal/api/dashboardbff/enrichment_cache.go @@ -21,7 +21,10 @@ import ( // are preserved). var ( // sessionsCacheTTL bounds how long a cached sessions read is served before a - // refetch. A var (not a const) so tests can shorten it. + // refetch. A var (not a const) so tests can shorten it — but it is captured + // by newRunTailerManager at construction (the sessions compute can run on + // the tailer loop's detached prime goroutine, where a live read of this var + // would race with a test mutating it), so set it BEFORE building the plane. sessionsCacheTTL = 3 * time.Second // formulaCacheTTL bounds how long a successfully-compiled formula detail is // served. Compiled formulas change rarely (an authored TOML edit), so this is diff --git a/internal/api/dashboardbff/links.go b/internal/api/dashboardbff/links.go new file mode 100644 index 0000000000..1095fb16b8 --- /dev/null +++ b/internal/api/dashboardbff/links.go @@ -0,0 +1,48 @@ +package dashboardbff + +import ( + "net/url" + "regexp" +) + +// The dashboard SPA is served by the supervisor listener, same-origin with +// this /api plane, and addresses one city at a time under a +// `/city/:cityName` router basename +// (internal/api/dashboardspa/web/frontend/src/CityBootstrap.tsx). The helpers +// below are the single source of truth for building deep links into that SPA +// from Go — the CLI and the HTTP API layer both import them — so the paths +// they return MUST mirror the SPA routes declared in App.tsx. + +// cityNameRE matches a managed city name: alphanumeric with internal hyphens, +// no path separators and no leading/trailing hyphen. +var cityNameRE = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$`) + +// ValidCityName reports whether name is a city name the dashboard serves: +// alphanumeric with internal hyphens, no leading/trailing hyphen, at most 64 +// characters. This is the exact grammar the /api plane checks before any +// resolver lookup (resolveCityPath) as a defensive measure — the +// authoritative path always comes from the resolver, never from joining the +// name — so a name this rejects is dashboard-unreachable and callers should +// not emit dashboard links for it. +func ValidCityName(name string) bool { + return name != "" && len(name) <= 64 && cityNameRE.MatchString(name) +} + +// RunDetailPath returns the dashboard SPA path for one run's detail view: +// /city/{cityName}/runs/{runID}. It mirrors the SPA's `/runs/:runId` route +// (internal/api/dashboardspa/web/frontend/src/App.tsx) under the +// `/city/:cityName` basename (CityBootstrap.tsx). runID is the run-root bead +// ID; only graph.v2 run roots render a detail view there (other roots show +// the list-only not_run_view page). Both segments are path-escaped. +func RunDetailPath(cityName, runID string) string { + return "/city/" + url.PathEscape(cityName) + "/runs/" + url.PathEscape(runID) +} + +// RunsListPath returns the dashboard SPA path for a city's runs list: +// /city/{cityName}/runs. It mirrors the SPA's `/runs` route +// (internal/api/dashboardspa/web/frontend/src/App.tsx) under the +// `/city/:cityName` basename (CityBootstrap.tsx). The segment is +// path-escaped. +func RunsListPath(cityName string) string { + return "/city/" + url.PathEscape(cityName) + "/runs" +} diff --git a/internal/api/dashboardbff/links_test.go b/internal/api/dashboardbff/links_test.go new file mode 100644 index 0000000000..fc5416856c --- /dev/null +++ b/internal/api/dashboardbff/links_test.go @@ -0,0 +1,61 @@ +package dashboardbff + +import ( + "strings" + "testing" +) + +func TestRunDetailPath(t *testing.T) { + tests := []struct { + name string + city string + runID string + want string + }{ + {name: "plain", city: "alpha", runID: "gcg-abc123", want: "/city/alpha/runs/gcg-abc123"}, + {name: "dotted run id", city: "alpha", runID: "run1.2", want: "/city/alpha/runs/run1.2"}, + { + name: "run id needing escaping", + city: "alpha", + runID: "a/b c%", + want: "/city/alpha/runs/a%2Fb%20c%25", + }, + { + name: "city needing escaping", + city: "a/b", + runID: "run1", + want: "/city/a%2Fb/runs/run1", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := RunDetailPath(tt.city, tt.runID); got != tt.want { + t.Errorf("RunDetailPath(%q, %q) = %q, want %q", tt.city, tt.runID, got, tt.want) + } + }) + } +} + +func TestRunsListPath(t *testing.T) { + if got, want := RunsListPath("alpha"), "/city/alpha/runs"; got != want { + t.Errorf("RunsListPath(alpha) = %q, want %q", got, want) + } + if got, want := RunsListPath("a b"), "/city/a%20b/runs"; got != want { + t.Errorf("RunsListPath(a b) = %q, want %q", got, want) + } +} + +func TestValidCityName(t *testing.T) { + valid := []string{"a", "alpha", "alpha-1", "A1-b2-C3", strings.Repeat("a", 64)} + for _, name := range valid { + if !ValidCityName(name) { + t.Errorf("ValidCityName(%q) = false, want true", name) + } + } + invalid := []string{"", "-a", "a-", "a_b", "a b", "a/b", "a.b", strings.Repeat("a", 65)} + for _, name := range invalid { + if ValidCityName(name) { + t.Errorf("ValidCityName(%q) = true, want false", name) + } + } +} diff --git a/internal/api/dashboardbff/plane.go b/internal/api/dashboardbff/plane.go index 48fb34f410..448a7f8ff6 100644 --- a/internal/api/dashboardbff/plane.go +++ b/internal/api/dashboardbff/plane.go @@ -218,7 +218,7 @@ func (p *Plane) registerRoutes() { // returns ("", false) for an unknown or malformed name; callers translate that // into a 404. func (p *Plane) resolveCityPath(name string) (string, bool) { - if !validCityName(name) || p.deps.Resolver == nil { + if !ValidCityName(name) || p.deps.Resolver == nil { return "", false } return p.deps.Resolver.CityPath(name) diff --git a/internal/api/dashboardbff/rundetail_grace.go b/internal/api/dashboardbff/rundetail_grace.go new file mode 100644 index 0000000000..e2266aca15 --- /dev/null +++ b/internal/api/dashboardbff/rundetail_grace.go @@ -0,0 +1,112 @@ +package dashboardbff + +import ( + "sync" + "time" +) + +// unknownRunWarmingGrace is how long the run-detail endpoints keep answering +// the retryable 503 "run view is warming" — instead of 404 — for a runId the +// WARM projection does not know, measured from the FIRST request for that +// runId. A run slung from the CLI is invisible to this projection until the +// controller's cache-reconcile emits its bead events onto the city's event +// log, a 30-120s cadence, so the window must exceed that cadence for a +// just-slung run's dashboard deep link to survive the gap. The contract is +// server-held: the server keeps answering "warming" (with reason unknown_run) +// for the whole window, the graced response's Retry-After header tells +// clients how often to poll, and the SPA's run-detail loader polls within its +// own retry budget (being extended in a sibling change) while treating a 404 +// as terminal. Once the window expires the endpoints restore the plain 404. +const unknownRunWarmingGrace = 180 * time.Second + +// unknownRunGraceMaxIDLen bounds the runId length inGrace will track. The +// entry cap (unknownRunGraceCap) bounds ENTRIES, not bytes: the map stores +// each runId verbatim, and the id arrives straight from the request path on +// the unauthenticated /api plane, so without a length bound a scanner +// spraying maximum-length URIs could pin ~cap x URI-length bytes of +// attacker-chosen data per city (~1 GiB with 1 MiB URIs). Real run roots are +// short bead IDs (tens of bytes), so 128 is generous headroom, never a +// functional limit. +const unknownRunGraceMaxIDLen = 128 + +// unknownRunGraceCap bounds how many unknown runIds one city's tracker holds +// at once, so a scanner spraying random runIds cannot grow the first-seen map +// without bound. When the map is full of live windows, a NEW unknown runId is +// simply not tracked (it degrades to today's immediate 404) rather than +// evicting a live window out from under an in-flight deep link. +const unknownRunGraceCap = 1024 + +// unknownRunGrace tracks the first time each truly-unknown runId was requested +// so the run-detail endpoints can serve the retryable warming 503 for a grace +// window before falling back to the terminal 404. It is concurrency-safe (the +// BFF serves concurrent requests) and bounded (unknownRunGraceCap). The clock +// is injectable for tests. +type unknownRunGrace struct { + window time.Duration + capacity int + now func() time.Time + + mu sync.Mutex + firstSeen map[string]time.Time +} + +// newUnknownRunGrace builds a tracker with the production window, cap, and +// wall clock. +func newUnknownRunGrace() *unknownRunGrace { + return &unknownRunGrace{ + window: unknownRunWarmingGrace, + capacity: unknownRunGraceCap, + now: time.Now, + firstSeen: make(map[string]time.Time), + } +} + +// inGrace reports whether runID is inside its warming-grace window, recording +// the first sighting when the runId is new. An expired entry is left in place +// (pruned lazily when the map needs room) so repeat polls for a dead runId +// keep getting the 404 instead of restarting the window. +func (g *unknownRunGrace) inGrace(runID string) bool { + // Refuse to track oversized runIds at all: an id longer than any real run + // root is never a legitimate just-slung run, and inserting it verbatim + // would let the unauthenticated /api plane fill the map with megabytes of + // attacker-chosen bytes per entry (see unknownRunGraceMaxIDLen). It + // degrades to the immediate 404. + if len(runID) > unknownRunGraceMaxIDLen { + return false + } + now := g.now() + g.mu.Lock() + defer g.mu.Unlock() + if first, ok := g.firstSeen[runID]; ok { + return now.Sub(first) < g.window + } + if len(g.firstSeen) >= g.capacity { + g.pruneExpiredLocked(now) + } + if len(g.firstSeen) >= g.capacity { + // Still full of live windows: do not track — the new unknown runId + // degrades to today's immediate 404 rather than evicting a live window. + return false + } + g.firstSeen[runID] = now + return true +} + +// forget drops runID's first-seen marker. Called when the projection resolves +// the run (it became known), so a known runId never lingers in the map. +// Idempotent and cheap for runIds that were never tracked. +func (g *unknownRunGrace) forget(runID string) { + g.mu.Lock() + delete(g.firstSeen, runID) + g.mu.Unlock() +} + +// pruneExpiredLocked removes every entry whose window has expired. The caller +// holds g.mu. +func (g *unknownRunGrace) pruneExpiredLocked(now time.Time) { + for id, first := range g.firstSeen { + if now.Sub(first) >= g.window { + delete(g.firstSeen, id) + } + } +} diff --git a/internal/api/dashboardbff/rundetail_grace_test.go b/internal/api/dashboardbff/rundetail_grace_test.go new file mode 100644 index 0000000000..30e1e83ca9 --- /dev/null +++ b/internal/api/dashboardbff/rundetail_grace_test.go @@ -0,0 +1,380 @@ +package dashboardbff + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/events" +) + +// graphRunRootEvent builds a graph.v2 run-root molecule for runID with the +// same scope metadata shape as runDetailRootEvent, so a test can append a +// SECOND run to a log that already carries run1. +func graphRunRootEvent(seq uint64, runID string) events.Event { + const formula = "mol-adopt-pr-v2" + return beadCreatedEvent(seq, beads.Bead{ + ID: runID, + Title: formula, + Status: "open", + Type: "molecule", + Ref: formula, + CreatedAt: time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC), + Metadata: map[string]string{ + "gc.formula_contract": "graph.v2", + "gc.kind": "run", + "gc.formula": formula, + "gc.run_target": "rig:demo", + "gc.root_store_ref": "rig:demo", + "gc.scope_kind": "rig", + "gc.scope_ref": "demo", + }, + }) +} + +// newTestGrace builds an unknownRunGrace with the production window, a +// test-chosen capacity, and a manually advanced clock. The returned *time.Time +// is the clock: tests move it forward directly (all access is single-goroutine). +func newTestGrace(capacity int) (*unknownRunGrace, *time.Time) { + cur := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + g := &unknownRunGrace{ + window: unknownRunWarmingGrace, + capacity: capacity, + now: func() time.Time { return cur }, + firstSeen: make(map[string]time.Time), + } + return g, &cur +} + +// TestUnknownRunGraceWindow proves the grace window is measured from the FIRST +// request for a runId: in-grace within the window, expired at/after it, and an +// expired runId stays expired (repeat polls must not restart the window). +func TestUnknownRunGraceWindow(t *testing.T) { + g, clock := newTestGrace(unknownRunGraceCap) + + if !g.inGrace("run-x") { + t.Fatal("first request for an unknown run must be in grace") + } + *clock = clock.Add(unknownRunWarmingGrace - time.Second) + if !g.inGrace("run-x") { + t.Fatal("request within the window must still be in grace") + } + *clock = clock.Add(2 * time.Second) + if g.inGrace("run-x") { + t.Fatal("request past the window must not be in grace") + } + if g.inGrace("run-x") { + t.Fatal("an expired runId must stay expired on repeat requests (no window restart)") + } +} + +// TestUnknownRunGraceForget proves a runId that becomes known is dropped from +// the first-seen map immediately (it must not linger until cap pruning). +func TestUnknownRunGraceForget(t *testing.T) { + g, _ := newTestGrace(unknownRunGraceCap) + + if !g.inGrace("run-x") { + t.Fatal("first request must be in grace") + } + g.forget("run-x") + g.mu.Lock() + _, lingering := g.firstSeen["run-x"] + n := len(g.firstSeen) + g.mu.Unlock() + if lingering || n != 0 { + t.Fatalf("forget left %d entries (run-x present=%v), want empty map", n, lingering) + } +} + +// TestUnknownRunGraceRefusesOversizedRunID proves an oversized runId is never +// tracked. The cap bounds ENTRIES, not bytes, so storing attacker-chosen ids +// verbatim would let a scanner spraying huge URIs at the unauthenticated /api +// plane pin ~cap x URI-length bytes per city. An oversized id must degrade to +// the immediate 404 (inGrace false) and leave the map untouched. +func TestUnknownRunGraceRefusesOversizedRunID(t *testing.T) { + g, _ := newTestGrace(unknownRunGraceCap) + + if g.inGrace(strings.Repeat("x", unknownRunGraceMaxIDLen+1)) { + t.Fatal("an oversized runId must not be graced") + } + g.mu.Lock() + n := len(g.firstSeen) + g.mu.Unlock() + if n != 0 { + t.Fatalf("map has %d entries after an oversized runId, want 0 (not tracked)", n) + } + // The bound is a security valve, not a functional limit: a runId at exactly + // the bound is still tracked normally. + if !g.inGrace(strings.Repeat("x", unknownRunGraceMaxIDLen)) { + t.Fatal("a runId at exactly the length bound must still be graced") + } +} + +// TestUnknownRunGraceCapEviction proves the first-seen map is bounded: a full +// map of live windows refuses new entries (they degrade to the plain 404, no +// live window is evicted), and expired entries are pruned to make room. +func TestUnknownRunGraceCapEviction(t *testing.T) { + g, clock := newTestGrace(2) + + if !g.inGrace("run-1") || !g.inGrace("run-2") { + t.Fatal("first two unknown runs must be tracked and in grace") + } + if g.inGrace("run-3") { + t.Fatal("a full map of live windows must refuse a new runId (degrade to 404)") + } + g.mu.Lock() + n := len(g.firstSeen) + g.mu.Unlock() + if n != 2 { + t.Fatalf("map has %d entries after refused insert, want 2 (cap)", n) + } + + // Expire the tracked windows: the next new runId prunes them and is tracked. + *clock = clock.Add(unknownRunWarmingGrace + time.Second) + if !g.inGrace("run-3") { + t.Fatal("after the live windows expire, a new runId must prune and be tracked") + } + g.mu.Lock() + _, r1 := g.firstSeen["run-1"] + _, r2 := g.firstSeen["run-2"] + _, r3 := g.firstSeen["run-3"] + n = len(g.firstSeen) + g.mu.Unlock() + if r1 || r2 || !r3 || n != 1 { + t.Fatalf("map after prune = %d entries (run-1=%v run-2=%v run-3=%v), want only run-3", n, r1, r2, r3) + } +} + +// graceTestPlane starts a plane over one city whose log already carries the +// canonical run1 root, warms the tailer, and installs a manually advanced clock +// on its unknown-run grace tracker. Everything runs on the test goroutine +// (ServeHTTP is synchronous), so the plain *time.Time clock is race-free. +func graceTestPlane(t *testing.T) (*Plane, string, *time.Time) { + t.Helper() + prev := runTailPollInterval + runTailPollInterval = 20 * time.Millisecond + t.Cleanup(func() { runTailPollInterval = prev }) + dir := t.TempDir() + writeEventLog(t, filepath.Join(dir, ".gc", "events.jsonl"), runDetailRootEvent()) + + p := New(Deps{Resolver: fakeResolver{paths: map[string]string{"alpha": dir}}}) + p.Start(t.Context()) + t.Cleanup(p.Stop) + + // Warm the tailer first (a summary read blocks on the cold replay), so an + // unknown run below is judged against the WARM projection. + _ = getRunSummary(t, p, "alpha") + + tl := p.runTailers.ensure("alpha", cityEventsPath(dir)) + cur := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + clock := &cur + tl.unknownRuns.now = func() time.Time { return *clock } + return p, dir, clock +} + +// expectGracedWarming asserts rec carries the graced unknown-run 503 wire +// contract: HTTP 503, Retry-After: 5, and the runDetailErrorBody +// {"error":"run view is warming","reason":"unknown_run"} — distinguishable from +// the cold-replay warming 503, which stays a plain {error} body with no +// Retry-After header. +func expectGracedWarming(t *testing.T, rec *httptest.ResponseRecorder) { + t.Helper() + expectRunDetailStatus(t, rec, http.StatusServiceUnavailable) + if got := rec.Header().Get("Retry-After"); got != "5" { + t.Fatalf("Retry-After = %q, want %q", got, "5") + } + var body runDetailErrorBody + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode graced 503 body: %v; body=%s", err, rec.Body.String()) + } + if body.Error != "run view is warming" || body.Reason != "unknown_run" { + t.Fatalf("graced 503 body = %+v, want error=%q reason=%q", body, "run view is warming", "unknown_run") + } +} + +// TestRunDetailEndpointUnknownRunWarmingGrace drives the JSON detail endpoint +// through the whole grace lifecycle for a truly-unknown run: the graced 503 +// (Retry-After + unknown_run reason) on the first request, still graced within +// the window, and the plain 404 restored once the window expires. +func TestRunDetailEndpointUnknownRunWarmingGrace(t *testing.T) { + p, _, clock := graceTestPlane(t) + + expectGracedWarming(t, getRunDetailRaw(t, p, "alpha", "missing")) + *clock = clock.Add(unknownRunWarmingGrace - time.Second) + expectGracedWarming(t, getRunDetailRaw(t, p, "alpha", "missing")) + *clock = clock.Add(2 * time.Second) + expectRunDetailStatus(t, getRunDetailRaw(t, p, "alpha", "missing"), http.StatusNotFound) +} + +// TestRunDetailEndpointOversizedRunIDGets404 drives the oversized-id refusal +// through the JSON endpoint: the very first request answers the plain 404 (no +// grace window ever starts) and the tracker's map stays empty. +func TestRunDetailEndpointOversizedRunIDGets404(t *testing.T) { + p, dir, _ := graceTestPlane(t) + tl := p.runTailers.ensure("alpha", cityEventsPath(dir)) + + huge := strings.Repeat("z", unknownRunGraceMaxIDLen+1) + expectRunDetailStatus(t, getRunDetailRaw(t, p, "alpha", huge), http.StatusNotFound) + tl.unknownRuns.mu.Lock() + n := len(tl.unknownRuns.firstSeen) + tl.unknownRuns.mu.Unlock() + if n != 0 { + t.Fatalf("grace map has %d entries after an oversized runId request, want 0", n) + } +} + +// TestRunDetailWarmingDoesNotStartGraceClock pins the check ORDER inside +// writeRunDetailReadError: the cold-replay warming answer (!ready) must win +// over — and must not consume — the unknown-run grace window. A not-found +// request during warming gets the PLAIN warming 503 (no Retry-After, no +// reason) and must not start the grace clock; the window is measured from the +// first POST-warm request, so even after a whole grace duration elapses during +// warming, the first warm request is still graced. A mutant that consults the +// grace tracker before the ready check starts (and here expires) the window +// during warming and answers 404 after warm-up, failing this test. +func TestRunDetailWarmingDoesNotStartGraceClock(t *testing.T) { + prevPoll := runTailPollInterval + prevWait := runColdLoadWait + runTailPollInterval = 20 * time.Millisecond + runColdLoadWait = 20 * time.Millisecond + t.Cleanup(func() { + runTailPollInterval = prevPoll + runColdLoadWait = prevWait + }) + dir := t.TempDir() + writeEventLog(t, filepath.Join(dir, ".gc", "events.jsonl"), runDetailRootEvent()) + + // Build the plane WITHOUT Start: the tailer exists but its fold loop is not + // running, so the projection stays in the warming (!ready) state. + p := New(Deps{Resolver: fakeResolver{paths: map[string]string{"alpha": dir}}}) + tl := p.runTailers.ensure("alpha", cityEventsPath(dir)) + cur := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + clock := &cur + tl.unknownRuns.now = func() time.Time { return *clock } + + rec := getRunDetailRaw(t, p, "alpha", "missing") + expectRunDetailStatus(t, rec, http.StatusServiceUnavailable) + if got := rec.Header().Get("Retry-After"); got != "" { + t.Fatalf("cold-replay warming 503 must not set Retry-After, got %q", got) + } + var body runDetailErrorBody + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode warming 503 body: %v; body=%s", err, rec.Body.String()) + } + if body.Reason != "" { + t.Fatalf("cold-replay warming 503 must carry no reason, got %q", body.Reason) + } + tl.unknownRuns.mu.Lock() + _, tracked := tl.unknownRuns.firstSeen["missing"] + tl.unknownRuns.mu.Unlock() + if tracked { + t.Fatal("a warming-phase request must not start the unknown-run grace clock") + } + + // The warming phase outlives an entire grace window... + *clock = clock.Add(unknownRunWarmingGrace + time.Second) + + // ...then the projection warms. The first post-warm request must STILL be + // graced — its window starts now, not during warming. + p.Start(t.Context()) + t.Cleanup(p.Stop) + select { + case <-tl.readyCh: + case <-time.After(5 * time.Second): + t.Fatal("tailer never finished its cold replay") + } + expectGracedWarming(t, getRunDetailRaw(t, p, "alpha", "missing")) +} + +// TestRunDetailEndpointKnownRunBypassesGrace proves a run the warm projection +// knows serves 200 untouched by the grace tracker, and that a runId which was +// unknown (tracked) and then appears in a later fold is dropped from the +// first-seen map on its next successful read. +func TestRunDetailEndpointKnownRunBypassesGrace(t *testing.T) { + p, dir, _ := graceTestPlane(t) + tl := p.runTailers.ensure("alpha", cityEventsPath(dir)) + + // Known run: plain 200, and no grace entry is ever recorded for it. + resp := getRunDetail(t, p, "alpha", "run1") + if resp.RunID != "run1" { + t.Fatalf("runId = %q, want run1", resp.RunID) + } + tl.unknownRuns.mu.Lock() + n := len(tl.unknownRuns.firstSeen) + tl.unknownRuns.mu.Unlock() + if n != 0 { + t.Fatalf("grace map has %d entries after a known-run read, want 0", n) + } + + // A run slung but not yet folded: tracked and graced... + expectRunDetailStatus(t, getRunDetailRaw(t, p, "alpha", "run2"), http.StatusServiceUnavailable) + // ...then its root event arrives (the cache-reconcile catches up). + appendEvents(t, filepath.Join(dir, ".gc", "events.jsonl"), graphRunRootEvent(2, "run2")) + + deadline := time.Now().Add(2 * time.Second) + for { + rec := getRunDetailRaw(t, p, "alpha", "run2") + if rec.Code == http.StatusOK { + break + } + if time.Now().After(deadline) { + t.Fatalf("run2 never became readable; last status=%d body=%s", rec.Code, rec.Body.String()) + } + time.Sleep(10 * time.Millisecond) + } + tl.unknownRuns.mu.Lock() + _, lingering := tl.unknownRuns.firstSeen["run2"] + tl.unknownRuns.mu.Unlock() + if lingering { + t.Fatal("run2 became known but still lingers in the grace map") + } +} + +// TestRunDetailEndpointNotRunViewUnaffectedByGrace proves the 422 not_run_view +// answer is untouched by the grace window: a v1/wisp run's FIRST request — the +// one an unknown run would get graced on — still returns the definitive 422. +func TestRunDetailEndpointNotRunViewUnaffectedByGrace(t *testing.T) { + dir := t.TempDir() + // A molecule run marker but NO gc.formula_contract=graph.v2 → not a run view. + writeEventLog(t, filepath.Join(dir, ".gc", "events.jsonl"), beadCreatedEvent(1, beads.Bead{ + ID: "v1run", + Title: "legacy v1 run", + Status: "open", + Type: "molecule", + CreatedAt: time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC), + Metadata: map[string]string{"gc.kind": "run"}, + })) + + p := New(Deps{Resolver: fakeResolver{paths: map[string]string{"alpha": dir}}}) + p.Start(t.Context()) + defer p.Stop() + _ = getRunSummary(t, p, "alpha") + + expectRunDetailStatus(t, getRunDetailRaw(t, p, "alpha", "v1run"), http.StatusUnprocessableEntity) + // And it stays 422 on a repeat — never demoted to warming or 404. + expectRunDetailStatus(t, getRunDetailRaw(t, p, "alpha", "v1run"), http.StatusUnprocessableEntity) +} + +// TestRunDetailStreamUnknownRunWarmingGrace mirrors the GET lifecycle on the +// SSE precheck: the graced 503 (Retry-After + unknown_run reason) inside the +// grace window, before any stream body — plain 404 after it expires. +func TestRunDetailStreamUnknownRunWarmingGrace(t *testing.T) { + p, _, clock := graceTestPlane(t) + + rec := httptest.NewRecorder() + p.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/city/alpha/runs/missing/detail/stream", nil)) + expectGracedWarming(t, rec) + + *clock = clock.Add(unknownRunWarmingGrace + time.Second) + rec = httptest.NewRecorder() + p.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/city/alpha/runs/missing/detail/stream", nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 after the grace window; body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/api/dashboardbff/rundetail_stream.go b/internal/api/dashboardbff/rundetail_stream.go index 2a7475025d..5c1a519ab7 100644 --- a/internal/api/dashboardbff/rundetail_stream.go +++ b/internal/api/dashboardbff/rundetail_stream.go @@ -3,7 +3,6 @@ package dashboardbff import ( "bytes" "context" - "errors" "fmt" "net/http" "time" @@ -113,11 +112,13 @@ func (p *Plane) handleRunDetailStream(w http.ResponseWriter, r *http.Request) { } // Precheck exactly like the GET so the HTTP error is returned BEFORE any SSE - // body is committed: 422 for an unsupported (v1/wisp) run, 404 for a missing - // run once warm, 503 while the projection is still warming. + // body is committed: 422 for an unsupported (v1/wisp) run, 503 while the + // projection is still warming or while a truly-unknown run is inside its + // warming-grace window, 404 for a missing run once warm. The shared + // writeRunDetailReadError keeps the two endpoints' mappings identical. value, ready, err := t.detail(r.Context(), runID) if err != nil { - writeRunDetailStreamPrecheckError(w, err, ready) + t.writeRunDetailReadError(w, runID, err, ready) return } @@ -131,26 +132,6 @@ func (p *Plane) handleRunDetailStream(w http.ResponseWriter, r *http.Request) { t.serveRunDetailStream(r.Context(), w, flusher, runID, value) } -// writeRunDetailStreamPrecheckError maps a failed precheck detail() read to the -// HTTP status the SPA's stream fallback expects, returned before any SSE body is -// committed: 422 for an unsupported (v1/wisp) run, 503 while the projection is -// still warming, 404 for a run absent once warm. -func writeRunDetailStreamPrecheckError(w http.ResponseWriter, err error, ready bool) { - var unsupported *runproj.UnsupportedRunError - if errors.As(err, &unsupported) { - writeJSON(w, http.StatusUnprocessableEntity, runDetailErrorBody{ - Error: unsupported.Message, - Reason: string(unsupported.Reason), - }) - return - } - if !ready { - writeError(w, http.StatusServiceUnavailable, "run view is warming") - return - } - writeError(w, http.StatusNotFound, "unknown run") -} - // writeRunDetailStreamHeaders commits the SSE response headers and the 200 status. // After this the response is an event-stream body, so every later failure is // surfaced by a failed frame write rather than an HTTP status. diff --git a/internal/api/dashboardbff/rundetail_stream_test.go b/internal/api/dashboardbff/rundetail_stream_test.go index e641b87aa0..e84c854acd 100644 --- a/internal/api/dashboardbff/rundetail_stream_test.go +++ b/internal/api/dashboardbff/rundetail_stream_test.go @@ -417,24 +417,9 @@ func TestRunDetailStreamUnsupportedRun422(t *testing.T) { } } -// TestRunDetailStreamUnknownRun404 confirms a missing run 404s once the tailer -// is warm, before any stream body. -func TestRunDetailStreamUnknownRun404(t *testing.T) { - dir := t.TempDir() - writeEventLog(t, filepath.Join(dir, ".gc", "events.jsonl"), runDetailRootEvent()) - p := New(Deps{Resolver: fakeResolver{paths: map[string]string{"alpha": dir}}}) - p.Start(t.Context()) - defer p.Stop() - - // Warm the tailer first so a missing run is a true 404, not a warming 503. - _ = getRunSummary(t, p, "alpha") - - rec := httptest.NewRecorder() - p.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/city/alpha/runs/missing/detail/stream", nil)) - if rec.Code != http.StatusNotFound { - t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String()) - } -} +// A missing run once the tailer is warm answers 503 for the unknown-run grace +// window and 404 after it expires, before any stream body — covered by +// TestRunDetailStreamUnknownRunWarmingGrace in rundetail_grace_test.go. // TestRunDetailStreamHeartbeat proves a heartbeat comment frame is emitted after // the (shortened) heartbeat interval when no data change fires. diff --git a/internal/api/dashboardbff/rundetailtailer_test.go b/internal/api/dashboardbff/rundetailtailer_test.go index dabc5f9e04..3ecfa4e4a2 100644 --- a/internal/api/dashboardbff/rundetailtailer_test.go +++ b/internal/api/dashboardbff/rundetailtailer_test.go @@ -354,22 +354,9 @@ func TestRunDetailEndpointUnknownCity404(t *testing.T) { } } -// TestRunDetailEndpointUnknownRun404 confirms a missing run 404s once the tailer -// is warm. -func TestRunDetailEndpointUnknownRun404(t *testing.T) { - dir := t.TempDir() - logPath := filepath.Join(dir, ".gc", "events.jsonl") - writeEventLog(t, logPath, runDetailRootEvent()) - - p := New(Deps{Resolver: fakeResolver{paths: map[string]string{"alpha": dir}}}) - p.Start(t.Context()) - defer p.Stop() - - // Warm the tailer first (a summary read blocks on the cold replay), so the - // missing run is a true 404, not a warming 503. - _ = getRunSummary(t, p, "alpha") - getRunDetailExpectStatus(t, p, "alpha", "missing", http.StatusNotFound) -} +// A missing run once the tailer is warm answers 503 for the unknown-run grace +// window and 404 after it expires — covered by +// TestRunDetailEndpointUnknownRunWarmingGrace in rundetail_grace_test.go. // TestRunDetailEndpointNotRunView maps a non-graph.v2 run to 422 with the // not_run_view reason so the SPA renders the honest list-only message. @@ -410,16 +397,15 @@ func getRunDetailRaw(t *testing.T, p *Plane, city, runID string) *httptest.Respo return rec } -func getRunDetailExpectStatus(t *testing.T, p *Plane, city, runID string, want int) { +func expectRunDetailStatus(t *testing.T, rec *httptest.ResponseRecorder, want int) { t.Helper() - rec := getRunDetailRaw(t, p, city, runID) if rec.Code != want { t.Fatalf("status = %d, want %d; body=%s", rec.Code, want, rec.Body.String()) } } // getRunDetail fetches a run's detail and decodes the success (200) body. Non-2xx -// paths use getRunDetailRaw / getRunDetailExpectStatus, so the expected status is +// paths use getRunDetailRaw / expectRunDetailStatus, so the expected status is // fixed here rather than a parameter. func getRunDetail(t *testing.T, p *Plane, city, runID string) runDetailWire { t.Helper() diff --git a/internal/api/dashboardbff/runtailer.go b/internal/api/dashboardbff/runtailer.go index 9571695d89..b7c778515a 100644 --- a/internal/api/dashboardbff/runtailer.go +++ b/internal/api/dashboardbff/runtailer.go @@ -53,6 +53,14 @@ type runTailerManager struct { sessionsCache *singleFlightCache[string, cachedSessions] formulaCache *singleFlightCache[formulaCacheKey, cachedFormulaDetail] + // sessionsTTL is the sessionsCacheTTL package var captured at construction. + // The sessions compute closure can run on the tailer loop's DETACHED prime + // goroutine (which Stop deliberately does not join), so reading the mutable + // package var there races with a test shortening it; the immutable capture + // keeps that read race-free while tests keep the set-var-then-construct + // convention. + sessionsTTL time.Duration + mu sync.Mutex cities map[string]*cityRunTailer ctx context.Context @@ -67,6 +75,7 @@ func newRunTailerManager(deps Deps) *runTailerManager { cities: make(map[string]*cityRunTailer), sessionsCache: newSingleFlightCache[string, cachedSessions](), formulaCache: newSingleFlightCache[formulaCacheKey, cachedFormulaDetail](), + sessionsTTL: sessionsCacheTTL, } } @@ -87,7 +96,7 @@ func (m *runTailerManager) ensure(name, eventsPath string) *cityRunTailer { defer m.mu.Unlock() t, ok := m.cities[name] if !ok { - t = &cityRunTailer{name: name, eventsPath: eventsPath, mgr: m, readyCh: make(chan struct{}), snapshotCache: newRunSnapshotCache(), detailMemo: newRunDetailMemo()} + t = &cityRunTailer{name: name, eventsPath: eventsPath, mgr: m, readyCh: make(chan struct{}), snapshotCache: newRunSnapshotCache(), detailMemo: newRunDetailMemo(), unknownRuns: newUnknownRunGrace()} m.cities[name] = t } if m.enabled && m.ctx != nil && !t.started { @@ -119,6 +128,11 @@ type cityRunTailer struct { snapshotCache *runSnapshotCache detailMemo *runDetailMemo + // unknownRuns grants a truly-unknown runId (a run slung but not yet folded + // into this projection) a warming-grace window on the detail endpoints + // before the terminal 404. See rundetail_grace.go. + unknownRuns *unknownRunGrace + mu sync.RWMutex summary runproj.RunSummary marks map[string]runproj.LaneProgressMark @@ -501,6 +515,11 @@ func (t *cityRunTailer) detail(ctx context.Context, runID string) (runDetailMemo return runDetailMemoValue{}, ready, err } + // The fold resolved this run's root, so the run is KNOWN to the projection: + // drop any unknown-run grace marker so a runId that becomes known never + // lingers in the first-seen map (rundetail_grace.go). + t.unknownRuns.forget(runID) + // Resolve the request-time sessions enrichment and its cache version. The // version (0 when unavailable) is part of the memo key so a sessions refresh — // or an availability flip — rebuilds. @@ -606,7 +625,10 @@ func (m *runTailerManager) fetchSessionsVersioned(ctx context.Context, name stri } // A successful sessions read is a positive last-good: serve it stale on a // later failed refetch rather than blanking the health card. - return cachedSessions{items: items}, sessionsCacheTTL, true, true + // m.sessionsTTL (not the sessionsCacheTTL var): this closure can run on + // the detached prime goroutine, so it must read the construction-time + // capture, never the test-mutable package var. + return cachedSessions{items: items}, m.sessionsTTL, true, true }) if !ok { return nil, 0, false @@ -824,30 +846,10 @@ func (p *Plane) registerRunDetail() { writeError(w, http.StatusNotFound, "unknown city") return } - value, ready, err := t.detail(r.Context(), r.PathValue("runId")) + runID := r.PathValue("runId") + value, ready, err := t.detail(r.Context(), runID) if err != nil { - var unsupported *runproj.UnsupportedRunError - if errors.As(err, &unsupported) { - writeJSON(w, http.StatusUnprocessableEntity, runDetailErrorBody{ - Error: unsupported.Message, - Reason: string(unsupported.Reason), - }) - return - } - // The run root is absent from the warm projection. While the cold replay - // is still in flight the fold may be incomplete, so report warming - // rather than a hard 404 for a run that may yet appear. This 503 is a - // retry signal, not a terminal error: the SPA loader - // (supervisor/runDetail.ts loadSupervisorFormulaRunDetail) already - // retries any 5xx — including this warming 503 — with bounded backoff - // before surfacing it, so the client re-polls until the replay finishes - // (covered by runDetail.test.ts "retries while the projection is - // warming"). - if !ready { - writeError(w, http.StatusServiceUnavailable, "run view is warming") - return - } - writeError(w, http.StatusNotFound, "unknown run") + t.writeRunDetailReadError(w, runID, err, ready) return } // Serve the memoized marshaled bytes verbatim — the memo already produced @@ -858,6 +860,72 @@ func (p *Plane) registerRunDetail() { }) } +// runDetailReasonUnknownRun is the runDetailErrorBody reason carried by the +// graced unknown-run 503, so clients can tell "the server is holding a grace +// window for a run it has never seen" apart from the cold-replay warming 503 +// (a plain {error} body with no reason and no Retry-After). +const runDetailReasonUnknownRun = "unknown_run" + +// unknownRunRetryAfter is the graced 503's Retry-After header value (seconds): +// the poll cadence the server suggests while it holds an unknown run's grace +// window open. +const unknownRunRetryAfter = "5" + +// writeRunDetailReadError maps a failed detail() read to the HTTP response — +// shared by the JSON GET and the SSE stream precheck so both endpoints answer +// identically: 422 for an unsupported (v1/wisp) run, 503 while the projection +// is still warming or while a truly-unknown run is inside its warming-grace +// window (the graced variant carries Retry-After and reason unknown_run), 404 +// otherwise. +func (t *cityRunTailer) writeRunDetailReadError(w http.ResponseWriter, runID string, err error, ready bool) { + var unsupported *runproj.UnsupportedRunError + if errors.As(err, &unsupported) { + // A definitive answer: the run root EXISTS in the projection but has no + // run-detail view. Checked BEFORE the grace window below — not_run_view + // must stay a 422, never a warming 503. + writeJSON(w, http.StatusUnprocessableEntity, runDetailErrorBody{ + Error: unsupported.Message, + Reason: string(unsupported.Reason), + }) + return + } + // The run root is absent from the warm projection. While the cold replay + // is still in flight the fold may be incomplete, so report warming rather + // than a hard 404 for a run that may yet appear. Checked BEFORE the grace + // window below: a warming-phase request must not start (or consume) an + // unknown run's grace clock — the window is measured from the first + // POST-warm request (TestRunDetailWarmingDoesNotStartGraceClock). This + // plain 503 is a retry signal for the short replay, and the SPA loader + // (supervisor/runDetail.ts loadSupervisorFormulaRunDetail) retries 5xx + // within its own bounded backoff budget before surfacing it (covered by + // runDetail.test.ts "retries while the projection is warming"). + if !ready { + writeError(w, http.StatusServiceUnavailable, "run view is warming") + return + } + // The projection is warm but has never seen this run. A run slung from the + // CLI stays invisible here until the controller's cache-reconcile emits + // its bead events (30-120s), and the SPA treats a 404 as terminal — so a + // truly-unknown run gets a retryable warming 503 for a grace window + // measured from its first request (rundetail_grace.go). The contract is + // server-held: the server holds the warming answer for the whole window, + // Retry-After tells clients how often to poll, and the SPA's run-detail + // loader polls within its own budget (being extended in a sibling change). + // The reason unknown_run makes this graced answer distinguishable from the + // cold-replay warming 503 above. Once the window expires the plain 404 + // below is restored. Only the not-found case is graced: every other + // failure keeps its existing mapping. + if errors.Is(err, runproj.ErrRunNotFound) && t.unknownRuns.inGrace(runID) { + w.Header().Set("Retry-After", unknownRunRetryAfter) + writeJSON(w, http.StatusServiceUnavailable, runDetailErrorBody{ + Error: "run view is warming", + Reason: runDetailReasonUnknownRun, + }) + return + } + writeError(w, http.StatusNotFound, "unknown run") +} + // cityRunTailer resolves the city to its run tailer, returning false for an // unknown city (so the handler can 404). Starting the fold loop is lazy. func (p *Plane) cityRunTailer(name string) (*cityRunTailer, bool) { @@ -897,7 +965,7 @@ func (p *Plane) eagerWarmTailers() { return } for _, c := range p.deps.Resolver.Cities() { - if !validCityName(c.Name) || c.Path == "" { + if !ValidCityName(c.Name) || c.Path == "" { continue } p.runTailers.ensure(c.Name, cityEventsPath(c.Path)) diff --git a/internal/api/dashboardbff/util.go b/internal/api/dashboardbff/util.go index 78a4a61a0f..b79c38fe2c 100644 --- a/internal/api/dashboardbff/util.go +++ b/internal/api/dashboardbff/util.go @@ -1,17 +1,5 @@ package dashboardbff -import "regexp" - -// cityNameRE matches a managed city name: alphanumeric with internal hyphens, -// no path separators and no leading/trailing hyphen. Names are validated -// before any resolver lookup as a defensive measure; the authoritative path -// always comes from the resolver, never from joining the name. -var cityNameRE = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$`) - -func validCityName(name string) bool { - return name != "" && len(name) <= 64 && cityNameRE.MatchString(name) -} - // firstNonEmpty returns a if it is non-empty, otherwise fallback. func firstNonEmpty(a, fallback string) string { if a != "" { diff --git a/internal/api/dashboardspa/dist/assets/Activity-C0ndMSgp.js b/internal/api/dashboardspa/dist/assets/Activity-DTboxwTI.js similarity index 98% rename from internal/api/dashboardspa/dist/assets/Activity-C0ndMSgp.js rename to internal/api/dashboardspa/dist/assets/Activity-DTboxwTI.js index 2500695240..acbb3f9ed0 100644 --- a/internal/api/dashboardspa/dist/assets/Activity-C0ndMSgp.js +++ b/internal/api/dashboardspa/dist/assets/Activity-DTboxwTI.js @@ -1,2 +1,2 @@ -import{J as _,I as q,a as P,K as B,b as F,j as t,B as V,L as W,a9 as $,aa as D,X as A,z as v,S as R,H as M}from"./index-BFDP6Xwd.js";import{r as C,c as b}from"./routeHighlight-B30gQO2o.js";import{P as z}from"./PageHeader-5RHLpIfH.js";import{b as G,a as H}from"./time-D9v0saHV.js";import{u as O}from"./useVisibleRefresh-Bxd6CPUo.js";const U=100,f="24h";async function K(e={}){const s=_("list supervisor events"),a=await q().listEvents(s,{limit:U,since:f,...e}),i=a.items??[];return i.sort((n,l)=>l.seq-n.seq),{...a,items:i,total:Number(a.total)}}const J=[{mode:"all",label:"All"},{mode:"events",label:"Events"},{mode:"deploys",label:"Deploys"},{mode:"commits",label:"Commits"}],L=[{value:"1h",label:"Last hour"},{value:f,label:"Last 24 hours"},{value:"7d",label:"Last 7 days"}],X=[{value:"all",label:"All signals"},{value:"attention",label:"Attention"},{value:"watch",label:"Watch"},{value:"event",label:"Event"}];function Ne(){const e=P(),[s,a]=B(),i=de(s),n=d(i,"events"),l=n?x(s.get("type")):null,o=n?x(s.get("actor")):null,r=n?me(s):f,c=n?pe(s):"all",h=n?x(s.get("q")):null,I=["activity:bundle",M()??"no-city",i,l??"all",o??"all",r,c,h??""].join(":"),{data:u,loading:m,error:S,refresh:k}=F(I,()=>Q(i,l,o,r,c,h));return O(k,3e4),t.jsxs("section",{children:[t.jsx(z,{title:"Activity",synopsis:ie(i,l),meta:t.jsxs(t.Fragment,{children:[S&&t.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:S}),t.jsx(V,{size:"sm",onClick:()=>{k()},disabled:m,children:m?"Refreshing":"Refresh"})]})}),t.jsx(Z,{active:i,eventType:l}),n&&t.jsx(ee,{eventType:l,eventActor:o,eventWindow:r,eventSignal:c,searchParams:s,setSearchParams:a,textFilter:h}),t.jsxs("div",{className:"mt-10 space-y-12",children:[d(i,"events")&&t.jsx(te,{events:u?.events??null,...u?.eventsError!==void 0?{error:u.eventsError}:{},filterActive:l!==null||o!==null||c!=="all"||h!==null,loading:m,attentionSeverity:g=>C(e,"activity",oe(g))}),d(i,"deploys")&&t.jsx(se,{deploys:u?.deploys??null,...u?.deploysError!==void 0?{error:u.deploysError}:{},loading:m,attentionSeverity:g=>C(e,"activity",ce(g))}),d(i,"commits")&&t.jsx(re,{commits:u?.commits??null,...u?.commitsError!==void 0?{error:u.commitsError}:{},loading:m})]})]})}async function Q(e,s,a,i,n,l){const[o,r,c]=await Promise.allSettled([d(e,"events")?Y(s,a,i,n,l):Promise.resolve(null),d(e,"deploys")?A.listBuilds():Promise.resolve(null),d(e,"commits")?A.listCommits("recent-all"):Promise.resolve(null)]);return{commits:j(c),...c.status==="rejected"?{commitsError:v(c.reason,"git commits unavailable")}:{},deploys:j(r),...r.status==="rejected"?{deploysError:v(r.reason,"deploy history unavailable")}:{},events:j(o),...o.status==="rejected"?{eventsError:v(o.reason,"event history unavailable")}:{}}}async function Y(e,s,a,i,n){const l=await K({since:a,...e===null?{}:{type:e},...s===null?{}:{actor:s}}),o=n?.toLowerCase()??"",r=l.items.filter(c=>e!==null&&c.type!==e||s!==null&&c.actor!==s||i!=="all"&&$(c)!==i?!1:o.length===0?!0:xe(c).includes(o));return{...l,items:r,total:r.length}}function j(e){return e.status==="fulfilled"?e.value:null}function Z({active:e,eventType:s}){return t.jsx("nav",{"aria-label":"Activity modes",children:t.jsx("ul",{className:"flex flex-wrap gap-2",children:J.map(({mode:a,label:i})=>{const n=e===a;return t.jsx("li",{children:t.jsx(W,{to:ue(a,s),"aria-current":n?"page":void 0,className:["inline-flex items-center rounded-sm border px-2.5 py-1 text-label uppercase tracking-wider transition-colors duration-150 ease-out-quart focus-mark",n?"border-fg text-fg":"border-rule text-fg-muted hover:text-fg hover:bg-surface-tint"].join(" "),children:i})},a)})})})}function ee({eventActor:e,eventSignal:s,eventType:a,eventWindow:i,searchParams:n,setSearchParams:l,textFilter:o}){return t.jsxs("div",{className:"mt-6 flex flex-wrap items-end gap-4",children:[t.jsxs("label",{className:"grid gap-1 text-label uppercase tracking-wider text-fg-muted",children:["Event window",t.jsx("select",{"aria-label":"Event window",value:i,onChange:r=>p(l,n,"since",r.currentTarget.value,f),className:"min-w-36 rounded-sm border border-rule bg-surface px-2 py-1 text-body normal-case tracking-normal text-fg focus-mark",children:L.map(r=>t.jsx("option",{value:r.value,children:r.label},r.value))})]}),t.jsxs("label",{className:"grid gap-1 text-label uppercase tracking-wider text-fg-muted",children:["Event type",t.jsx("input",{"aria-label":"Event type",value:a??"",onChange:r=>p(l,n,"type",r.currentTarget.value),placeholder:"session.crashed",className:"min-w-44 rounded-sm border border-rule bg-surface px-2 py-1 text-body normal-case tracking-normal text-fg placeholder:text-fg-faint focus-mark"})]}),t.jsxs("label",{className:"grid gap-1 text-label uppercase tracking-wider text-fg-muted",children:["Event actor",t.jsx("input",{"aria-label":"Event actor",value:e??"",onChange:r=>p(l,n,"actor",r.currentTarget.value),placeholder:"supervisor",className:"min-w-40 rounded-sm border border-rule bg-surface px-2 py-1 text-body normal-case tracking-normal text-fg placeholder:text-fg-faint focus-mark"})]}),t.jsxs("label",{className:"grid gap-1 text-label uppercase tracking-wider text-fg-muted",children:["Signal severity",t.jsx("select",{"aria-label":"Signal severity",value:s,onChange:r=>p(l,n,"signal",r.currentTarget.value,"all"),className:"min-w-36 rounded-sm border border-rule bg-surface px-2 py-1 text-body normal-case tracking-normal text-fg focus-mark",children:X.map(r=>t.jsx("option",{value:r.value,children:r.label},r.value))})]}),t.jsxs("label",{className:"grid min-w-56 flex-1 gap-1 text-label uppercase tracking-wider text-fg-muted",children:["Search activity",t.jsx("input",{"aria-label":"Search activity",value:o??"",onChange:r=>p(l,n,"q",r.currentTarget.value),placeholder:"actor, subject, or message",className:"rounded-sm border border-rule bg-surface px-2 py-1 text-body normal-case tracking-normal text-fg placeholder:text-fg-faint focus-mark"})]})]})}function te({error:e,events:s,filterActive:a,loading:i,attentionSeverity:n}){const l=s?.items??[],o=fe(s);return t.jsxs(y,{title:"Supervisor events",meta:s===null?null:`${s.total} events`,children:[e!==void 0&&t.jsxs("p",{className:"text-body text-accent",role:"alert",children:["Event history unavailable: ",e,"."]}),s?.partial===!0&&t.jsxs("p",{className:"text-body text-warn",children:["Event history incomplete",o.length>0?`: ${o.join("; ")}`:"."]}),t.jsxs(N,{label:"Supervisor events",children:[t.jsx("thead",{children:t.jsxs("tr",{className:"border-b border-rule text-label uppercase tracking-wider text-fg-muted",children:[t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Time"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Signal"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Type"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Subject"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Detail"})]})}),t.jsx("tbody",{children:l.length===0?t.jsx(w,{colSpan:5,children:i?"Reading supervisor events.":e!==void 0?"Event history unavailable.":a?"No supervisor events match these filters.":"No supervisor events in this window."}):l.map((r,c)=>t.jsxs("tr",{...b(n(r)),className:`border-b border-rule ${b(n(r)).className??""}`,children:[t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:t.jsx(E,{ts:r.ts})}),t.jsx("td",{className:"py-3 pr-6 align-baseline",children:t.jsx(le,{signal:$(r)})}),t.jsx("td",{className:"py-3 pr-6 align-baseline font-medium text-fg",children:r.type}),t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:r.subject??"·"}),t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:D(r)})]},`${r.seq}:${r.type}:${c}`))})]})]})}function se({deploys:e,error:s,loading:a,attentionSeverity:i}){const n=e?.items??[];return t.jsxs(y,{title:"Deploy history",meta:e?.failed_marker===!0?"failed marker present":e?.source??null,children:[s!==void 0&&t.jsxs("p",{className:"text-body text-accent",role:"alert",children:["Deploy history unavailable: ",s,"."]}),t.jsxs(N,{label:"Deploy history",children:[t.jsx("thead",{children:t.jsxs("tr",{className:"border-b border-rule text-label uppercase tracking-wider text-fg-muted",children:[t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Time"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Status"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Detail"})]})}),t.jsx("tbody",{children:n.length===0?t.jsx(w,{colSpan:3,children:a?"Reading deploy history.":"No deploy records in this window."}):n.map(l=>t.jsxs("tr",{...b(i(l)),className:`border-b border-rule ${b(i(l)).className??""}`,children:[t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:t.jsx(E,{ts:l.at})}),t.jsx("td",{className:"py-3 pr-6 align-baseline",children:t.jsx(ne,{deploy:l})}),t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:l.detail})]},`${l.at}:${l.detail}`))})]})]})}function re({commits:e,error:s,loading:a}){const i=e?.items??[];return t.jsxs(y,{title:"Git commits",meta:e===null?null:e.view,children:[s!==void 0&&t.jsxs("p",{className:"text-body text-accent",role:"alert",children:["Git commits unavailable: ",s,"."]}),t.jsxs(N,{label:"Git commits",children:[t.jsx("thead",{children:t.jsxs("tr",{className:"border-b border-rule text-label uppercase tracking-wider text-fg-muted",children:[t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Time"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Commit"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Author"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Subject"})]})}),t.jsx("tbody",{children:i.length===0?t.jsx(w,{colSpan:4,children:a?"Reading git commits.":"No commits in this window."}):i.map(n=>t.jsx(ae,{commit:n},n.sha))})]})]})}function ae({commit:e}){return t.jsxs("tr",{className:"border-b border-rule",children:[t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:t.jsx(E,{ts:e.date})}),t.jsx("td",{className:"py-3 pr-6 align-baseline font-medium text-fg",children:e.short_sha}),t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:e.author}),t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:e.subject})]})}function y({children:e,meta:s,title:a}){return t.jsxs("section",{"aria-labelledby":T(a),className:"space-y-4",children:[t.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[t.jsx("h2",{id:T(a),className:"text-headline font-semibold tracking-tight text-fg",children:a}),s!==null&&t.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:s})]}),e]})}function N({children:e,label:s}){return t.jsx("div",{className:"overflow-x-auto",children:t.jsx("table",{"aria-label":s,className:"w-full text-body tnum",children:e})})}function w({children:e,colSpan:s}){return t.jsx("tr",{children:t.jsx("td",{colSpan:s,className:"py-10 text-center text-fg-muted italic",children:e})})}function E({ts:e}){return t.jsx("span",{title:H(e),children:G(e)})}function le({signal:e}){const s=e==="attention"?"stuck":e==="watch"?"warn":"neutral";return t.jsx(R,{tone:s,label:e})}function ne({deploy:e}){const s=e.status==="ok"?"ok":e.status==="failed"?"stuck":e.status==="in-progress"?"warn":"neutral";return t.jsx(R,{tone:s,label:e.status})}function ie(e,s){return e==="events"&&s!==null?`Supervisor events filtered to ${s}.`:e==="events"?"Supervisor event history from the active city.":e==="deploys"?"Deploy history from dashboard-local project logs.":e==="commits"?"Recent git commits from the local project checkout.":"Supervisor events, deploy history, and recent project commits."}function oe(e){return`event:${String(e.seq)}:${e.type}`}function ce(e){return e.status==="failed"||e.status==="in-progress"?`deploy:${e.at}:${e.status}`:`deploy:${e.at}`}function ue(e,s){if(e==="all")return"/activity";const a=new URLSearchParams;return a.set("mode",e),e==="events"&&s!==null&&a.set("type",s),`/activity?${a.toString()}`}function d(e,s){return e==="all"||e===s}function de(e){const s=e.get("mode");return s==="events"||s==="deploys"||s==="commits"?s:"all"}function x(e){if(e===null)return null;const s=e.trim();return s.length===0?null:s}function me(e){const s=x(e.get("since"));return s!==null&&L.some(a=>a.value===s)?s:f}function pe(e){const s=x(e.get("signal"));return s==="attention"||s==="watch"||s==="event"?s:"all"}function p(e,s,a,i,n){const l=new URLSearchParams(s),o=i.trim();o.length===0||o===n?l.delete(a):l.set(a,o),e(l)}function xe(e){return[e.type,e.actor,e.subject,e.message,D(e)].filter(s=>typeof s=="string").join(` +import{J as _,I as q,a as P,K as B,b as F,j as t,B as V,L as W,a9 as $,aa as D,X as A,z as v,S as R,H as M}from"./index-C20tCZFz.js";import{r as C,c as b}from"./routeHighlight-B30gQO2o.js";import{P as z}from"./PageHeader-D_D-jYn1.js";import{b as G,a as H}from"./time-D9v0saHV.js";import{u as O}from"./useVisibleRefresh-D_HCcAAw.js";const U=100,f="24h";async function K(e={}){const s=_("list supervisor events"),a=await q().listEvents(s,{limit:U,since:f,...e}),i=a.items??[];return i.sort((n,l)=>l.seq-n.seq),{...a,items:i,total:Number(a.total)}}const J=[{mode:"all",label:"All"},{mode:"events",label:"Events"},{mode:"deploys",label:"Deploys"},{mode:"commits",label:"Commits"}],L=[{value:"1h",label:"Last hour"},{value:f,label:"Last 24 hours"},{value:"7d",label:"Last 7 days"}],X=[{value:"all",label:"All signals"},{value:"attention",label:"Attention"},{value:"watch",label:"Watch"},{value:"event",label:"Event"}];function Ne(){const e=P(),[s,a]=B(),i=de(s),n=d(i,"events"),l=n?x(s.get("type")):null,o=n?x(s.get("actor")):null,r=n?me(s):f,c=n?pe(s):"all",h=n?x(s.get("q")):null,I=["activity:bundle",M()??"no-city",i,l??"all",o??"all",r,c,h??""].join(":"),{data:u,loading:m,error:S,refresh:k}=F(I,()=>Q(i,l,o,r,c,h));return O(k,3e4),t.jsxs("section",{children:[t.jsx(z,{title:"Activity",synopsis:ie(i,l),meta:t.jsxs(t.Fragment,{children:[S&&t.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:S}),t.jsx(V,{size:"sm",onClick:()=>{k()},disabled:m,children:m?"Refreshing":"Refresh"})]})}),t.jsx(Z,{active:i,eventType:l}),n&&t.jsx(ee,{eventType:l,eventActor:o,eventWindow:r,eventSignal:c,searchParams:s,setSearchParams:a,textFilter:h}),t.jsxs("div",{className:"mt-10 space-y-12",children:[d(i,"events")&&t.jsx(te,{events:u?.events??null,...u?.eventsError!==void 0?{error:u.eventsError}:{},filterActive:l!==null||o!==null||c!=="all"||h!==null,loading:m,attentionSeverity:g=>C(e,"activity",oe(g))}),d(i,"deploys")&&t.jsx(se,{deploys:u?.deploys??null,...u?.deploysError!==void 0?{error:u.deploysError}:{},loading:m,attentionSeverity:g=>C(e,"activity",ce(g))}),d(i,"commits")&&t.jsx(re,{commits:u?.commits??null,...u?.commitsError!==void 0?{error:u.commitsError}:{},loading:m})]})]})}async function Q(e,s,a,i,n,l){const[o,r,c]=await Promise.allSettled([d(e,"events")?Y(s,a,i,n,l):Promise.resolve(null),d(e,"deploys")?A.listBuilds():Promise.resolve(null),d(e,"commits")?A.listCommits("recent-all"):Promise.resolve(null)]);return{commits:j(c),...c.status==="rejected"?{commitsError:v(c.reason,"git commits unavailable")}:{},deploys:j(r),...r.status==="rejected"?{deploysError:v(r.reason,"deploy history unavailable")}:{},events:j(o),...o.status==="rejected"?{eventsError:v(o.reason,"event history unavailable")}:{}}}async function Y(e,s,a,i,n){const l=await K({since:a,...e===null?{}:{type:e},...s===null?{}:{actor:s}}),o=n?.toLowerCase()??"",r=l.items.filter(c=>e!==null&&c.type!==e||s!==null&&c.actor!==s||i!=="all"&&$(c)!==i?!1:o.length===0?!0:xe(c).includes(o));return{...l,items:r,total:r.length}}function j(e){return e.status==="fulfilled"?e.value:null}function Z({active:e,eventType:s}){return t.jsx("nav",{"aria-label":"Activity modes",children:t.jsx("ul",{className:"flex flex-wrap gap-2",children:J.map(({mode:a,label:i})=>{const n=e===a;return t.jsx("li",{children:t.jsx(W,{to:ue(a,s),"aria-current":n?"page":void 0,className:["inline-flex items-center rounded-sm border px-2.5 py-1 text-label uppercase tracking-wider transition-colors duration-150 ease-out-quart focus-mark",n?"border-fg text-fg":"border-rule text-fg-muted hover:text-fg hover:bg-surface-tint"].join(" "),children:i})},a)})})})}function ee({eventActor:e,eventSignal:s,eventType:a,eventWindow:i,searchParams:n,setSearchParams:l,textFilter:o}){return t.jsxs("div",{className:"mt-6 flex flex-wrap items-end gap-4",children:[t.jsxs("label",{className:"grid gap-1 text-label uppercase tracking-wider text-fg-muted",children:["Event window",t.jsx("select",{"aria-label":"Event window",value:i,onChange:r=>p(l,n,"since",r.currentTarget.value,f),className:"min-w-36 rounded-sm border border-rule bg-surface px-2 py-1 text-body normal-case tracking-normal text-fg focus-mark",children:L.map(r=>t.jsx("option",{value:r.value,children:r.label},r.value))})]}),t.jsxs("label",{className:"grid gap-1 text-label uppercase tracking-wider text-fg-muted",children:["Event type",t.jsx("input",{"aria-label":"Event type",value:a??"",onChange:r=>p(l,n,"type",r.currentTarget.value),placeholder:"session.crashed",className:"min-w-44 rounded-sm border border-rule bg-surface px-2 py-1 text-body normal-case tracking-normal text-fg placeholder:text-fg-faint focus-mark"})]}),t.jsxs("label",{className:"grid gap-1 text-label uppercase tracking-wider text-fg-muted",children:["Event actor",t.jsx("input",{"aria-label":"Event actor",value:e??"",onChange:r=>p(l,n,"actor",r.currentTarget.value),placeholder:"supervisor",className:"min-w-40 rounded-sm border border-rule bg-surface px-2 py-1 text-body normal-case tracking-normal text-fg placeholder:text-fg-faint focus-mark"})]}),t.jsxs("label",{className:"grid gap-1 text-label uppercase tracking-wider text-fg-muted",children:["Signal severity",t.jsx("select",{"aria-label":"Signal severity",value:s,onChange:r=>p(l,n,"signal",r.currentTarget.value,"all"),className:"min-w-36 rounded-sm border border-rule bg-surface px-2 py-1 text-body normal-case tracking-normal text-fg focus-mark",children:X.map(r=>t.jsx("option",{value:r.value,children:r.label},r.value))})]}),t.jsxs("label",{className:"grid min-w-56 flex-1 gap-1 text-label uppercase tracking-wider text-fg-muted",children:["Search activity",t.jsx("input",{"aria-label":"Search activity",value:o??"",onChange:r=>p(l,n,"q",r.currentTarget.value),placeholder:"actor, subject, or message",className:"rounded-sm border border-rule bg-surface px-2 py-1 text-body normal-case tracking-normal text-fg placeholder:text-fg-faint focus-mark"})]})]})}function te({error:e,events:s,filterActive:a,loading:i,attentionSeverity:n}){const l=s?.items??[],o=fe(s);return t.jsxs(y,{title:"Supervisor events",meta:s===null?null:`${s.total} events`,children:[e!==void 0&&t.jsxs("p",{className:"text-body text-accent",role:"alert",children:["Event history unavailable: ",e,"."]}),s?.partial===!0&&t.jsxs("p",{className:"text-body text-warn",children:["Event history incomplete",o.length>0?`: ${o.join("; ")}`:"."]}),t.jsxs(N,{label:"Supervisor events",children:[t.jsx("thead",{children:t.jsxs("tr",{className:"border-b border-rule text-label uppercase tracking-wider text-fg-muted",children:[t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Time"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Signal"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Type"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Subject"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Detail"})]})}),t.jsx("tbody",{children:l.length===0?t.jsx(w,{colSpan:5,children:i?"Reading supervisor events.":e!==void 0?"Event history unavailable.":a?"No supervisor events match these filters.":"No supervisor events in this window."}):l.map((r,c)=>t.jsxs("tr",{...b(n(r)),className:`border-b border-rule ${b(n(r)).className??""}`,children:[t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:t.jsx(E,{ts:r.ts})}),t.jsx("td",{className:"py-3 pr-6 align-baseline",children:t.jsx(le,{signal:$(r)})}),t.jsx("td",{className:"py-3 pr-6 align-baseline font-medium text-fg",children:r.type}),t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:r.subject??"·"}),t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:D(r)})]},`${r.seq}:${r.type}:${c}`))})]})]})}function se({deploys:e,error:s,loading:a,attentionSeverity:i}){const n=e?.items??[];return t.jsxs(y,{title:"Deploy history",meta:e?.failed_marker===!0?"failed marker present":e?.source??null,children:[s!==void 0&&t.jsxs("p",{className:"text-body text-accent",role:"alert",children:["Deploy history unavailable: ",s,"."]}),t.jsxs(N,{label:"Deploy history",children:[t.jsx("thead",{children:t.jsxs("tr",{className:"border-b border-rule text-label uppercase tracking-wider text-fg-muted",children:[t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Time"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Status"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Detail"})]})}),t.jsx("tbody",{children:n.length===0?t.jsx(w,{colSpan:3,children:a?"Reading deploy history.":"No deploy records in this window."}):n.map(l=>t.jsxs("tr",{...b(i(l)),className:`border-b border-rule ${b(i(l)).className??""}`,children:[t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:t.jsx(E,{ts:l.at})}),t.jsx("td",{className:"py-3 pr-6 align-baseline",children:t.jsx(ne,{deploy:l})}),t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:l.detail})]},`${l.at}:${l.detail}`))})]})]})}function re({commits:e,error:s,loading:a}){const i=e?.items??[];return t.jsxs(y,{title:"Git commits",meta:e===null?null:e.view,children:[s!==void 0&&t.jsxs("p",{className:"text-body text-accent",role:"alert",children:["Git commits unavailable: ",s,"."]}),t.jsxs(N,{label:"Git commits",children:[t.jsx("thead",{children:t.jsxs("tr",{className:"border-b border-rule text-label uppercase tracking-wider text-fg-muted",children:[t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Time"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Commit"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Author"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Subject"})]})}),t.jsx("tbody",{children:i.length===0?t.jsx(w,{colSpan:4,children:a?"Reading git commits.":"No commits in this window."}):i.map(n=>t.jsx(ae,{commit:n},n.sha))})]})]})}function ae({commit:e}){return t.jsxs("tr",{className:"border-b border-rule",children:[t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:t.jsx(E,{ts:e.date})}),t.jsx("td",{className:"py-3 pr-6 align-baseline font-medium text-fg",children:e.short_sha}),t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:e.author}),t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:e.subject})]})}function y({children:e,meta:s,title:a}){return t.jsxs("section",{"aria-labelledby":T(a),className:"space-y-4",children:[t.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[t.jsx("h2",{id:T(a),className:"text-headline font-semibold tracking-tight text-fg",children:a}),s!==null&&t.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:s})]}),e]})}function N({children:e,label:s}){return t.jsx("div",{className:"overflow-x-auto",children:t.jsx("table",{"aria-label":s,className:"w-full text-body tnum",children:e})})}function w({children:e,colSpan:s}){return t.jsx("tr",{children:t.jsx("td",{colSpan:s,className:"py-10 text-center text-fg-muted italic",children:e})})}function E({ts:e}){return t.jsx("span",{title:H(e),children:G(e)})}function le({signal:e}){const s=e==="attention"?"stuck":e==="watch"?"warn":"neutral";return t.jsx(R,{tone:s,label:e})}function ne({deploy:e}){const s=e.status==="ok"?"ok":e.status==="failed"?"stuck":e.status==="in-progress"?"warn":"neutral";return t.jsx(R,{tone:s,label:e.status})}function ie(e,s){return e==="events"&&s!==null?`Supervisor events filtered to ${s}.`:e==="events"?"Supervisor event history from the active city.":e==="deploys"?"Deploy history from dashboard-local project logs.":e==="commits"?"Recent git commits from the local project checkout.":"Supervisor events, deploy history, and recent project commits."}function oe(e){return`event:${String(e.seq)}:${e.type}`}function ce(e){return e.status==="failed"||e.status==="in-progress"?`deploy:${e.at}:${e.status}`:`deploy:${e.at}`}function ue(e,s){if(e==="all")return"/activity";const a=new URLSearchParams;return a.set("mode",e),e==="events"&&s!==null&&a.set("type",s),`/activity?${a.toString()}`}function d(e,s){return e==="all"||e===s}function de(e){const s=e.get("mode");return s==="events"||s==="deploys"||s==="commits"?s:"all"}function x(e){if(e===null)return null;const s=e.trim();return s.length===0?null:s}function me(e){const s=x(e.get("since"));return s!==null&&L.some(a=>a.value===s)?s:f}function pe(e){const s=x(e.get("signal"));return s==="attention"||s==="watch"||s==="event"?s:"all"}function p(e,s,a,i,n){const l=new URLSearchParams(s),o=i.trim();o.length===0||o===n?l.delete(a):l.set(a,o),e(l)}function xe(e){return[e.type,e.actor,e.subject,e.message,D(e)].filter(s=>typeof s=="string").join(` `).toLowerCase()}function fe(e){const s=e?.partial_errors;return Array.isArray(s)?s.filter(a=>typeof a=="string"&&a.length>0):[]}function T(e){return`activity-${e.toLowerCase().replace(/[^a-z0-9]+/g,"-")}`}export{Ne as ActivityPage}; diff --git a/internal/api/dashboardspa/dist/assets/AgentDetail-4AW6d3TF.js b/internal/api/dashboardspa/dist/assets/AgentDetail-DVT9Be-a.js similarity index 96% rename from internal/api/dashboardspa/dist/assets/AgentDetail-4AW6d3TF.js rename to internal/api/dashboardspa/dist/assets/AgentDetail-DVT9Be-a.js index d6b8d24048..389ba00146 100644 --- a/internal/api/dashboardspa/dist/assets/AgentDetail-4AW6d3TF.js +++ b/internal/api/dashboardspa/dist/assets/AgentDetail-DVT9Be-a.js @@ -1 +1 @@ -import{j as e,B as T,r,p as F,q as ie,t as ce,v as oe,w as de,u as ue,x as U,l as me,y as xe,z as X,f as fe,A as ge,C as he,L as K,s as pe,S as je,G as W}from"./index-BFDP6Xwd.js";import{u as be,R as Ne,B as we}from"./BeadDetailModal-BKOlUSQL.js";import{P as D}from"./PageHeader-5RHLpIfH.js";import{f as O}from"./time-D9v0saHV.js";import{P as ve}from"./constants-DOaI3lZl.js";import{L as ye,a as Ae}from"./LiveSessionPeek-Cjv3DcC3.js";import{e as Ce}from"./context-window-Cu9zl36t.js";import{f as Se}from"./agentReads-DcDPDlRM.js";import"./format-fte2CeYD.js";import"./Field-BpdGqWpv.js";function ke({beads:n,error:c,loading:l,onSelect:i}){return e.jsxs("section",{className:"mb-12",children:[e.jsxs("header",{className:"flex items-baseline justify-between mb-4",children:[e.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Beads assigned"}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:l?"·":n.length})]}),c!==null?e.jsx("p",{className:"text-body text-accent",role:"alert",children:c}):l?e.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading beads."}):n.length===0?e.jsx("p",{className:"text-body text-fg-muted italic",children:"No beads assigned to this agent."}):e.jsx("ul",{className:"space-y-2",children:n.map(a=>e.jsxs("li",{className:"flex items-baseline gap-3 min-w-0",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum shrink-0",children:a.id}),e.jsx("button",{type:"button",onClick:()=>i(a),className:"text-body text-fg hover:text-accent truncate min-w-0 text-left focus-mark",title:`Open ${a.id}`,children:a.title}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint shrink-0",children:a.status})]},a.id))})]})}function _e({messages:n,loading:c,error:l,now:i}){return e.jsxs("section",{className:"mt-12",children:[e.jsxs("header",{className:"flex items-baseline justify-between mb-4",children:[e.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Chat thread"}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:c?"·":n.length})]}),e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint mb-4",children:e.jsxs("span",{className:"text-accent",children:["▲ ",ve]})}),c?e.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading messages."}):l!==null?e.jsx("p",{className:"text-body text-accent",role:"alert",children:l}):n.length===0?e.jsx("p",{className:"text-body text-fg-muted italic",children:"No messages between operator and this agent."}):e.jsx("ul",{className:"space-y-6",children:n.map(a=>e.jsxs("li",{className:"space-y-2 pb-4 border-b border-rule last:border-0",children:[e.jsxs("header",{className:"flex items-baseline justify-between gap-3",children:[e.jsxs("div",{className:"text-label uppercase tracking-wider text-fg-muted truncate",children:[e.jsx("span",{className:"text-fg font-medium",children:a.from}),e.jsx("span",{className:"mx-1.5 text-fg-faint",children:"→"}),e.jsx("span",{children:a.to})]}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum shrink-0",children:O(a.created_at,i)})]}),a.subject&&e.jsx("p",{className:"text-body font-medium text-fg",children:a.subject}),e.jsx("pre",{className:"text-body whitespace-pre-wrap leading-relaxed text-fg overflow-x-auto",children:a.body})]},a.id))})]})}function Ee({alias:n,prompt:c,loading:l,error:i,onRefresh:a}){const j=i?.status===404||i?.kind==="not_found",f=c!==null?`${c.length.toLocaleString()} chars`:l?"loading":i!==null?"—":"·";return e.jsxs("section",{className:"mt-12",children:[e.jsxs("header",{className:"flex items-baseline justify-between mb-4",children:[e.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Directives"}),e.jsxs("div",{className:"flex items-baseline gap-3",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:f}),e.jsx(T,{size:"sm",tone:"quiet",onClick:a,disabled:l,children:l?"Refreshing":"Refresh"})]})]}),l&&c===null&&i===null?e.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading directives."}):j?e.jsxs("p",{className:"text-body text-warn",children:["Agent ",e.jsx("code",{className:"text-fg",children:n})," has no entry in city config."]}):i!==null?e.jsxs("p",{className:"text-body text-accent",role:"alert",children:[i.status?`${i.status} `:"",i.message]}):c!==null?e.jsx("pre",{className:"text-body whitespace-pre-wrap leading-relaxed text-fg overflow-x-auto max-h-[60vh] overflow-y-auto",children:c}):null]})}function Le({session:n}){return e.jsxs("section",{children:[e.jsx("header",{className:"flex items-baseline justify-between mb-4",children:e.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Live peek"})}),e.jsx(ye,{sessionId:n.id,stream:Ae(n),showBadge:!0,showCaption:!0})]})}function Be({session:n,now:c}){const l=Ce(n),i=[{label:"Rig",value:n.rig??"·"},{label:"Pool",value:n.pool??"·"},{label:"Provider",value:n.provider??"·"},{label:"Model",value:n.model??"·"},{label:"Context",value:typeof l=="number"?e.jsxs("span",{className:`tnum ${l>=95?"text-accent":l>=80?"text-warn":"text-fg"}`,children:[l,"%"]}):"·"},{label:"Attached",value:n.attached?"yes":"no"},{label:"Created",value:e.jsx("span",{className:"tnum",children:O(n.created_at,c)})},{label:"Last active",value:e.jsx("span",{className:"tnum",children:O(n.last_active,c)})}];return e.jsx("dl",{className:"grid grid-cols-2 sm:grid-cols-4 gap-x-8 gap-y-5 mb-12",children:i.map(a=>e.jsxs("div",{children:[e.jsx("dt",{className:"text-label uppercase tracking-wider text-fg-faint mb-1",children:a.label}),e.jsx("dd",{className:"text-body text-fg",children:a.value})]},a.label))})}const Me=2e3,Re=6e4;function Pe({enabled:n,intervalMs:c,load:l,formatError:i,initialBackoffMs:a=Me,maxBackoffMs:j=Re}){const[f,g]=r.useState({status:"idle"}),b=r.useRef(0),h=r.useRef(0);return r.useEffect(()=>{if(!n){b.current=0,h.current=0,g({status:"idle"});return}let N=!1,p=new AbortController;const w=()=>{b.current=0,h.current=0},v=()=>{const u=Math.min(a*2**b.current,j);b.current+=1,h.current=Date.now()+u},y=async()=>{if(Date.now()m.status==="ready"?{...m,refreshing:!0,error:""}:{status:"loading"});try{const m=await l(u.signal);if(N||u.signal.aborted)return;w(),g({status:"ready",data:m,refreshing:!1,error:""})}catch(m){if(N||u.signal.aborted)return;v();const C=i?i(m):F(m);g(E=>E.status==="ready"?{...E,refreshing:!1,error:C}:{status:"failed",error:C})}};y();const A=window.setInterval(()=>{document.hidden||y()},c);return()=>{N=!0,p.abort(),window.clearInterval(A)}},[n,c,l,i,a,j]),f}const Ie=1e4,J=200;function Ue(){const{slug:n=""}=ie(),c=ce(),{viewingAs:l}=oe(),i=de(),[a,j]=r.useState(null),[f,g]=r.useState(null),[b,h]=r.useState(null),[N,p]=r.useState(null),[w,v]=r.useState(null),[y,A]=r.useState(null),u=ue(),[m,C]=r.useState(null),[E,V]=r.useState(!1),[Q,$]=r.useState(null),S=r.useMemo(()=>{try{return decodeURIComponent(n)}catch(t){return U({component:"AgentDetail",operation:"decodeSlug",message:F(t)}),n}},[n]),M=r.useCallback(async()=>{try{const{items:t}=await me();j(t??[]),p(null)}catch(t){p(t instanceof Error?t.message:"sessions failed")}},[]),s=r.useMemo(()=>a===null?null:a.find(t=>t.session_name===S)??a.find(t=>t.alias===S)??a.find(t=>t.id===S)??null,[a,S]),R=r.useMemo(()=>s===null?[]:[s.alias??"",s.session_name,s.id],[s]),P=r.useCallback(async()=>{if(R.length===0){g([]),h(null);return}try{const{items:t}=await xe(R,{includeClosed:!0});g(t),h(null)}catch(t){g([]),h(X(t,"assigned beads unavailable")),U({component:"AgentDetail",operation:"refreshBeads",message:F(t)})}},[R]);r.useEffect(()=>{M()},[M]),r.useEffect(()=>{P()},[P]),fe([W.session,W.bead],()=>{M(),P()});const Y=r.useMemo(()=>{if(s===null||f===null)return[];const t=new Set;return s.alias&&t.add(s.alias),s.session_name&&t.add(s.session_name),t.add(s.id),f.filter(o=>{if(o.assignee!==void 0&&t.has(o.assignee))return!0;const d=o.metadata;return!!(d&&(d.session_id===s.id||d.session_name&&d.session_name===s.session_name))})},[s,f]),q=r.useMemo(()=>{if(s===null)return[];const t=new Set;return s.alias&&t.add(s.alias.toLowerCase()),s.session_name&&t.add(s.session_name.toLowerCase()),t.add(s.id.toLowerCase()),[...t]},[s]),z=r.useMemo(()=>[l.alias.toLowerCase(),i.operatorWireAlias.toLowerCase()],[l.alias,i.operatorWireAlias]),Z=r.useCallback(async()=>{const{items:t}=await ge("all",l.alias,i);return t},[l.alias,i]),x=Pe({enabled:s!==null,intervalMs:Ie,load:Z,formatError:X}),ee=x.status==="loading",se=x.status==="failed"||x.status==="ready"&&x.error.length>0?x.error:null,k=r.useMemo(()=>s===null?null:s.alias??s.template??null,[s]),te=r.useCallback(async()=>{if(k!==null){V(!0),$(null);try{const t=await Se(k);C(t.prompt)}catch(t){const o=he(t,"directives fetch failed"),d={message:o.message};o.status!==void 0&&(d.status=o.status),o.kind!==void 0&&(d.kind=o.kind),$(d),C(null)}finally{V(!1)}}},[k]),I=be(s?.id??null),ae=r.useMemo(()=>{const t=x.status==="ready"?x.data:[],o=new Set(q),d=new Set(z),_=t.filter(L=>{const B=(L.from??"").toLowerCase(),H=(L.to??"").toLowerCase();return!!(d.has(B)&&o.has(H)||o.has(B)&&d.has(H))});return _.sort((L,B)=>L.created_at.localeCompare(B.created_at)),_.length>J?_.slice(_.length-J):_},[x,q,z]);if(a===null)return e.jsx("section",{children:e.jsx(D,{title:"Agent",synopsis:"Loading session list."})});if(s===null)return e.jsxs("section",{children:[e.jsx(D,{title:"Agent",synopsis:e.jsxs(e.Fragment,{children:["No session matches ",e.jsx("code",{className:"text-fg",children:S}),"."]}),meta:e.jsx(T,{size:"sm",tone:"quiet",onClick:()=>c("/agents"),children:"← Agents"})}),e.jsxs("p",{className:"text-body text-fg-muted max-w-prose",children:["The slug doesn't match any current session's session_name, alias, or id. Sessions are listed at"," ",e.jsx(K,{to:"/agents",className:"text-accent hover:underline",children:"/agents"}),"."]})]});const ne=s.alias??s.title??s.id,re=pe(s.state),G=t=>{v(null),A(t)},le=()=>{v(null),A(null)};return e.jsxs("section",{children:[e.jsx(D,{title:ne,synopsis:e.jsxs("span",{className:"flex flex-wrap items-baseline gap-x-3 gap-y-1",children:[e.jsx(je,{tone:re,label:s.state,...s.attached?{trailing:"att"}:{},...s.reason?{title:`reason: ${s.reason}`}:{}}),e.jsx("span",{className:"text-fg-faint",children:"·"}),e.jsx("code",{className:"text-fg-muted",children:s.template??"—"}),s.session_name&&s.session_name!==s.alias&&e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"text-fg-faint",children:"·"}),e.jsx("span",{className:"text-fg-faint",children:s.session_name})]}),e.jsx("span",{className:"text-fg-faint",children:"·"}),e.jsxs("span",{className:"text-fg-faint",children:["id ",e.jsx("code",{className:"text-fg-muted",children:s.id})]})]}),meta:e.jsx(K,{to:"/agents",children:e.jsx(T,{size:"sm",tone:"quiet",children:"← Agents"})})}),N&&e.jsx("p",{className:"text-body text-accent mb-6",role:"alert",children:N}),e.jsx(Be,{session:s,now:u}),e.jsx(ke,{beads:Y,error:b,loading:f===null,onSelect:t=>{A(null),v(t)}}),e.jsx(Ne,{view:I.view,loading:I.loading,error:I.error,now:u,onOpenBead:G}),e.jsx(Le,{session:s}),k!==null&&e.jsx(Ee,{alias:k,prompt:m,loading:E,error:Q,onRefresh:()=>{te()}}),e.jsx(_e,{messages:ae,loading:ee,error:se,now:u}),e.jsx(we,{open:w!==null||y!==null,onClose:le,beadId:w?.id??y,initialBead:w,onOpenBead:G})]})}export{Ue as AgentDetailPage}; +import{j as e,B as T,r,p as F,q as ie,t as ce,v as oe,w as de,u as ue,x as U,l as me,y as xe,z as X,f as fe,A as ge,C as he,L as K,s as pe,S as je,G as W}from"./index-C20tCZFz.js";import{u as be,R as Ne,B as we}from"./BeadDetailModal-BtVrX_Fu.js";import{P as D}from"./PageHeader-D_D-jYn1.js";import{f as O}from"./time-D9v0saHV.js";import{P as ve}from"./constants-DBKWGg29.js";import{L as ye,a as Ae}from"./LiveSessionPeek-jm19JJ4Z.js";import{e as Ce}from"./context-window-Cu9zl36t.js";import{f as Se}from"./agentReads-C0EYRgYm.js";import"./format-fte2CeYD.js";import"./Field-Dsl4x4KL.js";function ke({beads:n,error:c,loading:l,onSelect:i}){return e.jsxs("section",{className:"mb-12",children:[e.jsxs("header",{className:"flex items-baseline justify-between mb-4",children:[e.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Beads assigned"}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:l?"·":n.length})]}),c!==null?e.jsx("p",{className:"text-body text-accent",role:"alert",children:c}):l?e.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading beads."}):n.length===0?e.jsx("p",{className:"text-body text-fg-muted italic",children:"No beads assigned to this agent."}):e.jsx("ul",{className:"space-y-2",children:n.map(a=>e.jsxs("li",{className:"flex items-baseline gap-3 min-w-0",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum shrink-0",children:a.id}),e.jsx("button",{type:"button",onClick:()=>i(a),className:"text-body text-fg hover:text-accent truncate min-w-0 text-left focus-mark",title:`Open ${a.id}`,children:a.title}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint shrink-0",children:a.status})]},a.id))})]})}function _e({messages:n,loading:c,error:l,now:i}){return e.jsxs("section",{className:"mt-12",children:[e.jsxs("header",{className:"flex items-baseline justify-between mb-4",children:[e.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Chat thread"}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:c?"·":n.length})]}),e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint mb-4",children:e.jsxs("span",{className:"text-accent",children:["▲ ",ve]})}),c?e.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading messages."}):l!==null?e.jsx("p",{className:"text-body text-accent",role:"alert",children:l}):n.length===0?e.jsx("p",{className:"text-body text-fg-muted italic",children:"No messages between operator and this agent."}):e.jsx("ul",{className:"space-y-6",children:n.map(a=>e.jsxs("li",{className:"space-y-2 pb-4 border-b border-rule last:border-0",children:[e.jsxs("header",{className:"flex items-baseline justify-between gap-3",children:[e.jsxs("div",{className:"text-label uppercase tracking-wider text-fg-muted truncate",children:[e.jsx("span",{className:"text-fg font-medium",children:a.from}),e.jsx("span",{className:"mx-1.5 text-fg-faint",children:"→"}),e.jsx("span",{children:a.to})]}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum shrink-0",children:O(a.created_at,i)})]}),a.subject&&e.jsx("p",{className:"text-body font-medium text-fg",children:a.subject}),e.jsx("pre",{className:"text-body whitespace-pre-wrap leading-relaxed text-fg overflow-x-auto",children:a.body})]},a.id))})]})}function Ee({alias:n,prompt:c,loading:l,error:i,onRefresh:a}){const j=i?.status===404||i?.kind==="not_found",f=c!==null?`${c.length.toLocaleString()} chars`:l?"loading":i!==null?"—":"·";return e.jsxs("section",{className:"mt-12",children:[e.jsxs("header",{className:"flex items-baseline justify-between mb-4",children:[e.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Directives"}),e.jsxs("div",{className:"flex items-baseline gap-3",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:f}),e.jsx(T,{size:"sm",tone:"quiet",onClick:a,disabled:l,children:l?"Refreshing":"Refresh"})]})]}),l&&c===null&&i===null?e.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading directives."}):j?e.jsxs("p",{className:"text-body text-warn",children:["Agent ",e.jsx("code",{className:"text-fg",children:n})," has no entry in city config."]}):i!==null?e.jsxs("p",{className:"text-body text-accent",role:"alert",children:[i.status?`${i.status} `:"",i.message]}):c!==null?e.jsx("pre",{className:"text-body whitespace-pre-wrap leading-relaxed text-fg overflow-x-auto max-h-[60vh] overflow-y-auto",children:c}):null]})}function Le({session:n}){return e.jsxs("section",{children:[e.jsx("header",{className:"flex items-baseline justify-between mb-4",children:e.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Live peek"})}),e.jsx(ye,{sessionId:n.id,stream:Ae(n),showBadge:!0,showCaption:!0})]})}function Be({session:n,now:c}){const l=Ce(n),i=[{label:"Rig",value:n.rig??"·"},{label:"Pool",value:n.pool??"·"},{label:"Provider",value:n.provider??"·"},{label:"Model",value:n.model??"·"},{label:"Context",value:typeof l=="number"?e.jsxs("span",{className:`tnum ${l>=95?"text-accent":l>=80?"text-warn":"text-fg"}`,children:[l,"%"]}):"·"},{label:"Attached",value:n.attached?"yes":"no"},{label:"Created",value:e.jsx("span",{className:"tnum",children:O(n.created_at,c)})},{label:"Last active",value:e.jsx("span",{className:"tnum",children:O(n.last_active,c)})}];return e.jsx("dl",{className:"grid grid-cols-2 sm:grid-cols-4 gap-x-8 gap-y-5 mb-12",children:i.map(a=>e.jsxs("div",{children:[e.jsx("dt",{className:"text-label uppercase tracking-wider text-fg-faint mb-1",children:a.label}),e.jsx("dd",{className:"text-body text-fg",children:a.value})]},a.label))})}const Me=2e3,Re=6e4;function Pe({enabled:n,intervalMs:c,load:l,formatError:i,initialBackoffMs:a=Me,maxBackoffMs:j=Re}){const[f,g]=r.useState({status:"idle"}),b=r.useRef(0),h=r.useRef(0);return r.useEffect(()=>{if(!n){b.current=0,h.current=0,g({status:"idle"});return}let N=!1,p=new AbortController;const w=()=>{b.current=0,h.current=0},v=()=>{const u=Math.min(a*2**b.current,j);b.current+=1,h.current=Date.now()+u},y=async()=>{if(Date.now()m.status==="ready"?{...m,refreshing:!0,error:""}:{status:"loading"});try{const m=await l(u.signal);if(N||u.signal.aborted)return;w(),g({status:"ready",data:m,refreshing:!1,error:""})}catch(m){if(N||u.signal.aborted)return;v();const C=i?i(m):F(m);g(E=>E.status==="ready"?{...E,refreshing:!1,error:C}:{status:"failed",error:C})}};y();const A=window.setInterval(()=>{document.hidden||y()},c);return()=>{N=!0,p.abort(),window.clearInterval(A)}},[n,c,l,i,a,j]),f}const Ie=1e4,J=200;function Ue(){const{slug:n=""}=ie(),c=ce(),{viewingAs:l}=oe(),i=de(),[a,j]=r.useState(null),[f,g]=r.useState(null),[b,h]=r.useState(null),[N,p]=r.useState(null),[w,v]=r.useState(null),[y,A]=r.useState(null),u=ue(),[m,C]=r.useState(null),[E,V]=r.useState(!1),[Q,$]=r.useState(null),S=r.useMemo(()=>{try{return decodeURIComponent(n)}catch(t){return U({component:"AgentDetail",operation:"decodeSlug",message:F(t)}),n}},[n]),M=r.useCallback(async()=>{try{const{items:t}=await me();j(t??[]),p(null)}catch(t){p(t instanceof Error?t.message:"sessions failed")}},[]),s=r.useMemo(()=>a===null?null:a.find(t=>t.session_name===S)??a.find(t=>t.alias===S)??a.find(t=>t.id===S)??null,[a,S]),R=r.useMemo(()=>s===null?[]:[s.alias??"",s.session_name,s.id],[s]),P=r.useCallback(async()=>{if(R.length===0){g([]),h(null);return}try{const{items:t}=await xe(R,{includeClosed:!0});g(t),h(null)}catch(t){g([]),h(X(t,"assigned beads unavailable")),U({component:"AgentDetail",operation:"refreshBeads",message:F(t)})}},[R]);r.useEffect(()=>{M()},[M]),r.useEffect(()=>{P()},[P]),fe([W.session,W.bead],()=>{M(),P()});const Y=r.useMemo(()=>{if(s===null||f===null)return[];const t=new Set;return s.alias&&t.add(s.alias),s.session_name&&t.add(s.session_name),t.add(s.id),f.filter(o=>{if(o.assignee!==void 0&&t.has(o.assignee))return!0;const d=o.metadata;return!!(d&&(d.session_id===s.id||d.session_name&&d.session_name===s.session_name))})},[s,f]),q=r.useMemo(()=>{if(s===null)return[];const t=new Set;return s.alias&&t.add(s.alias.toLowerCase()),s.session_name&&t.add(s.session_name.toLowerCase()),t.add(s.id.toLowerCase()),[...t]},[s]),z=r.useMemo(()=>[l.alias.toLowerCase(),i.operatorWireAlias.toLowerCase()],[l.alias,i.operatorWireAlias]),Z=r.useCallback(async()=>{const{items:t}=await ge("all",l.alias,i);return t},[l.alias,i]),x=Pe({enabled:s!==null,intervalMs:Ie,load:Z,formatError:X}),ee=x.status==="loading",se=x.status==="failed"||x.status==="ready"&&x.error.length>0?x.error:null,k=r.useMemo(()=>s===null?null:s.alias??s.template??null,[s]),te=r.useCallback(async()=>{if(k!==null){V(!0),$(null);try{const t=await Se(k);C(t.prompt)}catch(t){const o=he(t,"directives fetch failed"),d={message:o.message};o.status!==void 0&&(d.status=o.status),o.kind!==void 0&&(d.kind=o.kind),$(d),C(null)}finally{V(!1)}}},[k]),I=be(s?.id??null),ae=r.useMemo(()=>{const t=x.status==="ready"?x.data:[],o=new Set(q),d=new Set(z),_=t.filter(L=>{const B=(L.from??"").toLowerCase(),H=(L.to??"").toLowerCase();return!!(d.has(B)&&o.has(H)||o.has(B)&&d.has(H))});return _.sort((L,B)=>L.created_at.localeCompare(B.created_at)),_.length>J?_.slice(_.length-J):_},[x,q,z]);if(a===null)return e.jsx("section",{children:e.jsx(D,{title:"Agent",synopsis:"Loading session list."})});if(s===null)return e.jsxs("section",{children:[e.jsx(D,{title:"Agent",synopsis:e.jsxs(e.Fragment,{children:["No session matches ",e.jsx("code",{className:"text-fg",children:S}),"."]}),meta:e.jsx(T,{size:"sm",tone:"quiet",onClick:()=>c("/agents"),children:"← Agents"})}),e.jsxs("p",{className:"text-body text-fg-muted max-w-prose",children:["The slug doesn't match any current session's session_name, alias, or id. Sessions are listed at"," ",e.jsx(K,{to:"/agents",className:"text-accent hover:underline",children:"/agents"}),"."]})]});const ne=s.alias??s.title??s.id,re=pe(s.state),G=t=>{v(null),A(t)},le=()=>{v(null),A(null)};return e.jsxs("section",{children:[e.jsx(D,{title:ne,synopsis:e.jsxs("span",{className:"flex flex-wrap items-baseline gap-x-3 gap-y-1",children:[e.jsx(je,{tone:re,label:s.state,...s.attached?{trailing:"att"}:{},...s.reason?{title:`reason: ${s.reason}`}:{}}),e.jsx("span",{className:"text-fg-faint",children:"·"}),e.jsx("code",{className:"text-fg-muted",children:s.template??"—"}),s.session_name&&s.session_name!==s.alias&&e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"text-fg-faint",children:"·"}),e.jsx("span",{className:"text-fg-faint",children:s.session_name})]}),e.jsx("span",{className:"text-fg-faint",children:"·"}),e.jsxs("span",{className:"text-fg-faint",children:["id ",e.jsx("code",{className:"text-fg-muted",children:s.id})]})]}),meta:e.jsx(K,{to:"/agents",children:e.jsx(T,{size:"sm",tone:"quiet",children:"← Agents"})})}),N&&e.jsx("p",{className:"text-body text-accent mb-6",role:"alert",children:N}),e.jsx(Be,{session:s,now:u}),e.jsx(ke,{beads:Y,error:b,loading:f===null,onSelect:t=>{A(null),v(t)}}),e.jsx(Ne,{view:I.view,loading:I.loading,error:I.error,now:u,onOpenBead:G}),e.jsx(Le,{session:s}),k!==null&&e.jsx(Ee,{alias:k,prompt:m,loading:E,error:Q,onRefresh:()=>{te()}}),e.jsx(_e,{messages:ae,loading:ee,error:se,now:u}),e.jsx(we,{open:w!==null||y!==null,onClose:le,beadId:w?.id??y,initialBead:w,onOpenBead:G})]})}export{Ue as AgentDetailPage}; diff --git a/internal/api/dashboardspa/dist/assets/Agents-sZ3Kn-9C.js b/internal/api/dashboardspa/dist/assets/Agents-CF9gHKR0.js similarity index 97% rename from internal/api/dashboardspa/dist/assets/Agents-sZ3Kn-9C.js rename to internal/api/dashboardspa/dist/assets/Agents-CF9gHKR0.js index d7bc8ffb63..fb2653cfb2 100644 --- a/internal/api/dashboardspa/dist/assets/Agents-sZ3Kn-9C.js +++ b/internal/api/dashboardspa/dist/assets/Agents-CF9gHKR0.js @@ -1,2 +1,2 @@ -import{r as d,s as T,j as e,L as I,u as te,S as $,B as y,a as fe,b as R,l as xe,c as he,d as be,e as ve,f as je,g as Ne,h as ye,R as ke,i as H,k as we,G as K,m as Ce,n as Se,o as Ae}from"./index-BFDP6Xwd.js";import{e as X}from"./context-window-Cu9zl36t.js";import{r as J,a as Re}from"./routeHighlight-B30gQO2o.js";import{i as $e,c as O,a as Ie,s as _e,b as se,d as E,e as Me,L as Le}from"./projectOf-CJPpTC86.js";import{M as ne}from"./constants-DOaI3lZl.js";import{P as Pe}from"./PageHeader-5RHLpIfH.js";import{S as Oe,P as Ee}from"./SseIndicator-DGo-aCtn.js";import{f as ae}from"./time-D9v0saHV.js";import{L as ie,i as Q}from"./LiveSessionPeek-Cjv3DcC3.js";import{T as Te}from"./Table-3q0HSJQI.js";import{l as Be}from"./agentReads-DcDPDlRM.js";import"./format-fte2CeYD.js";function qe(s){const n=s.indexOf("-");if(n<=0)return!1;const o=s.slice(0,n),l=s.slice(n+1);return!l||!/^[a-z0-9]+$/.test(l)||!(o==="gc"||o==="td"||o==="th"||/^[a-z]{4}$/.test(o))?!1:/[0-9]/.test(l)}function ze(s){const n=s.trim();if(qe(n))return{role:n,sessionId:n};for(let o=n.length-1;o>=0;o--){const l=n.charAt(o);if(l!=="-"&&l!=="_"&&l!=="/")continue;const c=n.slice(o+1);if(c&&/^(?:gc|td|th|[a-z]{4})-[a-z0-9]{1,32}$/.test(c))return{role:n.slice(0,o),sessionId:c}}return{role:n}}const We="in_progress";function De(s){return Ie(_e(s).label)}function Fe(s){const n=O(s.template??"");return n.length>0?n:O(s.session_name??s.id)}function Z(s){const n=s.session.last_active?Date.parse(s.session.last_active):NaN;return Number.isFinite(n)?n:0}function Ue(s,n){const o=new Map;for(const r of n){if(r.status!==We)continue;const i=r.assignee?.trim();if(!i)continue;const{sessionId:m}=ze(i);m&&!o.has(m)&&o.set(m,r)}const l=[];for(const r of s){if(!$e(r))continue;const i=o.get(r.id);l.push({session:r,rig:De(r),worker:Fe(r),...i?{bead:i}:{}})}l.sort((r,i)=>Z(i)-Z(r));const c=new Map;for(const r of l)c.set(r.rig,(c.get(r.rig)??0)+1);const u=Array.from(c,([r,i])=>({rig:r,count:i})).sort((r,i)=>i.count-r.count||r.rig.localeCompare(i.rig));return{workers:l,byRig:u,total:l.length}}function Ve(s){if(s.total===0)return"No workers active right now.";const n=s.total===1?"worker":"workers",o=s.byRig.map(l=>`${l.rig} (${l.count})`).join(", ");return`${s.total} ${n} active across ${o}.`}function Ye({worker:s,accent:n,onPeek:o}){const l=te(),{session:c,rig:u,bead:r}=s,i=n?T(c.state):"neutral";return e.jsx("li",{className:"px-2 py-2 -mx-2 rounded-sm transition-colors duration-150 ease-out-quart hover:bg-surface-tint/60",children:e.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[e.jsxs("div",{className:"min-w-0 text-body text-fg",children:[e.jsxs("button",{type:"button",onClick:()=>o(c.id),className:"group text-left cursor-pointer focus-mark",title:`Open ${u} · ${s.worker} transcript`,children:[e.jsx("span",{className:"font-medium group-hover:text-accent",children:u}),e.jsxs("span",{className:"text-fg-faint","aria-hidden":"true",children:[" ","·"," "]}),e.jsx("span",{className:"text-fg-muted group-hover:text-accent",children:s.worker})]}),r&&e.jsxs(I,{to:`/beads?bead=${encodeURIComponent(r.id)}`,className:"hover:text-accent focus-mark",title:`Open ${r.id}`,children:[e.jsxs("span",{className:"text-fg-faint","aria-hidden":"true",children:[" ","→"," "]}),e.jsx("span",{className:"tnum text-fg-muted",children:r.id}),e.jsxs("span",{className:"text-fg-muted",children:[": ",r.title]})]})]}),e.jsxs("div",{className:"flex items-baseline gap-3 shrink-0",children:[e.jsx($,{tone:i,label:c.state}),e.jsx("span",{className:"tnum text-fg-muted w-10 text-right",children:ae(c.last_active,l)}),e.jsx(y,{size:"sm",tone:"quiet",onClick:()=>o(c.id),children:"Peek"})]})]})})}function Ge(s){return s.running===!0||s.state==="active"||s.state==="running"}function He({beads:s,sessions:n,sessionsLoading:o,sessionsError:l}){const c=d.useMemo(()=>Ue(n,s),[n,s]),u=d.useMemo(()=>Ve(c),[c]),[r,i]=d.useState(null),m=d.useMemo(()=>r?c.workers.find(f=>f.session.id===r)??null:null,[c.workers,r]),k=d.useMemo(()=>c.workers.findIndex(f=>T(f.session.state)==="stuck"),[c.workers]),p=n.length===0,w=l!==null&&p,N=o&&p,_=w||N?"—":c.total;return e.jsxs("section",{className:"mb-10","aria-label":"Workers active",children:[e.jsxs("header",{className:"flex items-baseline justify-between border-b border-rule pb-2 mb-4",children:[e.jsx("h2",{className:"text-headline text-fg",children:"Workers active"}),e.jsx("span",{className:"text-label tnum text-fg-muted",children:_})]}),w?e.jsx("p",{className:"text-body text-fg-muted",role:"status",children:"Worker status unavailable."}):N?e.jsx("p",{className:"text-body text-fg-muted",role:"status",children:"Checking worker status…"}):c.total===0?e.jsx("p",{className:"text-body text-fg-muted",children:"No workers active right now."}):e.jsxs(e.Fragment,{children:[e.jsx("p",{className:"text-body text-fg-muted mb-4",children:u}),e.jsx("ul",{className:"space-y-1",children:c.workers.map((f,C)=>e.jsx(Ye,{worker:f,accent:C===k,onPeek:i},f.session.id))})]}),e.jsx(ne,{open:m!==null,onClose:()=>i(null),title:m?`${m.rig} · ${m.worker}`:"Transcript",caption:m?.bead?e.jsxs(I,{to:`/beads?bead=${encodeURIComponent(m.bead.id)}`,className:"text-fg-muted hover:text-accent focus-mark",title:`Open ${m.bead.id}`,children:[e.jsx("span",{className:"tnum",children:m.bead.id}),e.jsxs("span",{children:[": ",m.bead.title]})]}):"Live transcript from the supervisor's session stream.",widthClass:"max-w-5xl",children:e.jsx(ie,{sessionId:r,stream:m?Ge(m.session):!1,showBadge:!0,showCaption:!0})})]})}function ee(s){return s.session?.name??s.name}function Ke(s){return!s.suspended&&(s.state==="active"||s.state==="running"||s.running===!0)}function Xe(s,n){return Ke(s)||n==="attention"}function P(s){const n=O(s.name);return se(s)?n:`${E(s).label} · ${n}`}const Je=s=>[s.name,s.display_name,s.pool,s.rig,s.provider,s.model].filter(n=>typeof n=="string"&&n.length>0);function ft(){const s=fe(),{data:n,loading:o,error:l,refresh:c}=R("agents",Be),u=R("sessions",xe),r=R("beads:in-flight",()=>he()),i=d.useMemo(()=>n?.items??[],[n]),m=d.useMemo(()=>(u.data?.items??[]).map(t=>t.id).sort(),[u.data]),k=d.useMemo(()=>i.map(t=>t.name).sort(),[i]),p=R(`agent-pending:${k.join(",")}:${m.join(",")}`,()=>be(i,u.data?.items??[])),w=d.useMemo(()=>{const t=new Map;for(const a of u.data?.items??[])a.session_name&&t.set(a.session_name,a.id);return t},[u.data]),N=d.useMemo(()=>{const t=new Map;for(const a of p.data??[])t.set(a.agentName,a);return t},[p.data]),_=d.useMemo(()=>{const t=(p.data??[]).map(g=>({agentName:g.agentName,...g.pending.prompt===void 0?{}:{prompt:g.pending.prompt}})),a=new Map(i.map(g=>[g.name,g]));return ve(i,t).flatMap(g=>{const b=a.get(g.name);return b===void 0?[]:[{need:g,label:P(b),slug:ee(b)}]})},[i,p.data]),f=te(),[C,oe]=d.useState(!0),[M,re]=d.useState(""),[v,B]=d.useState(""),[S,q]=d.useState(null),[z,W]=d.useState(null),[D,F]=d.useState(null),[j,U]=d.useState(null),x=d.useMemo(()=>S===null?null:i.find(t=>t.name===S)??null,[i,S]),V=d.useMemo(()=>{const t=x?.session?.name;return t?w.get(t)??null:null},[x,w]),le=je([K.session,K.bead,"agent."],()=>{c(),r.refresh(),u.refresh()}),ce=d.useMemo(()=>st(i),[i]),h=Ne(),L=d.useCallback(async(t,a)=>{if(!h){U({sessionId:t.sessionId,action:a}),W(null),F(null);try{await ye(t.sessionId,{action:a,request_id:t.pending.request_id}),W(`responded to ${t.agentName}`),await p.refresh()}catch(g){F(g instanceof Error?g.message:"response failed")}finally{U(null)}}},[p,h]),A=d.useMemo(()=>Array.from(new Set(i.filter(t=>!se(t)).map(t=>E(t).label))).sort((t,a)=>t.localeCompare(a)),[i]);d.useEffect(()=>{v!==""&&!A.includes(v)&&B("")},[A,v]);const Y=d.useMemo(()=>{const t=M.trim().toLowerCase();return i.filter(a=>{if(v!==""&&E(a).label!==v)return!1;const g=J(s,"agents",a.name);return C&&!Xe(a,g)?!1:t.length===0?!0:Je(a).some(b=>b.toLowerCase().includes(t))})},[i,v,C,M,s]),de=d.useMemo(()=>t=>Re(J(s,"agents",t.name)),[s]),G=l!==null&&i.length===0,ue=G?"Agent roster unavailable.":i.length===0?"No agents configured.":"No agents match the current search or filter.",me=d.useMemo(()=>[{key:"name",label:"Agent",sortable:!0,sortValue:t=>P(t),render:t=>{const a=Me(t),g=t.display_name&&t.display_name!==t.name?t.display_name:t.provider??t.model??"",b=!t.session,ge=b?`${t.name} — configured but not running; detail will show no live session`:`Open drilldown for ${t.name}`,pe=b?"text-fg-muted":"text-fg";return e.jsxs("div",{className:"min-w-0",children:[e.jsx(I,{to:`/agents/${encodeURIComponent(ee(t))}`,className:`block ${pe} truncate hover:text-accent focus-mark ${a?"font-normal italic":"font-medium"}`,title:ge,children:P(t)}),g&&e.jsx("div",{className:"text-label uppercase tracking-wider text-fg-faint mt-1 truncate",children:g})]})}},{key:"state",label:"State",sortable:!0,sortValue:t=>t.state,render:t=>e.jsx($,{tone:T(t.state),label:t.state,...t.session?.attached?{trailing:"att"}:{},...t.unavailable_reason?{title:`unavailable: ${t.unavailable_reason}`}:{}}),className:"w-32"},{key:"activity",label:"Activity",sortable:!0,sortValue:t=>t.activity??"",render:t=>{const a=N.get(t.name);return a!==void 0?e.jsxs("div",{className:"min-w-0",children:[e.jsx($,{tone:"stuck",label:"needs you"}),e.jsx("p",{className:"mt-1 truncate text-fg-muted",title:a.pending.prompt,children:a.pending.prompt??a.pending.kind})]}):e.jsx("span",{className:"text-fg-muted",children:t.activity??(t.running?"running":"·")})},className:"w-28"},{key:"context",label:"Context",sortable:!0,sortValue:t=>X(t)??-1,align:"right",render:t=>{const a=X(t);if(typeof a!="number")return e.jsx("span",{className:"text-fg-faint",children:"·"});const g=typeof t.context_pct=="number"&&t.context_pct!==a?`gc reports ${t.context_pct}% against ${t.context_window??"?"}-token window; scaled to model's true window`:void 0;return e.jsxs("span",{title:g,className:`tnum ${a>=95?"text-accent font-medium":a>=80?"text-warn font-medium":"text-fg-muted"}`,children:[a,"%"]})},className:"w-24"},{key:"last_active",label:"Last active",sortable:!0,sortValue:t=>t.session?.last_activity??"",render:t=>{const a=t.session?.last_activity;return a?e.jsx("span",{className:"tnum text-fg-muted",children:ae(a,f)}):e.jsx("span",{className:"text-fg-faint tnum",children:"·"})},className:"w-32"},{key:"actions",label:"",render:t=>{if(!t.session)return null;const a=N.get(t.name);return e.jsxs("div",{className:"flex justify-end gap-2",children:[a!==void 0&&e.jsxs(e.Fragment,{children:[h&&e.jsx(ke,{}),e.jsx(y,{size:"sm",tone:"quiet",title:h?H:void 0,disabled:h||j?.sessionId===a.sessionId,onClick:()=>{L(a,"approve")},children:j?.sessionId===a.sessionId&&j.action==="approve"?"Approving":"Approve"}),e.jsx(y,{size:"sm",tone:"quiet",title:h?H:void 0,disabled:h||j?.sessionId===a.sessionId,onClick:()=>{L(a,"deny")},children:j?.sessionId===a.sessionId&&j.action==="deny"?"Denying":"Deny"}),e.jsx(Ze,{command:we(t.name)})]}),e.jsx(y,{size:"sm",tone:"quiet",onClick:()=>q(t.name),children:"Peek"})]})},align:"right",className:"w-80"}],[L,f,N,h,j]);return e.jsxs("section",{children:[e.jsx(Pe,{title:"Agents",synopsis:G?"Agent roster unavailable.":ce,meta:e.jsxs(e.Fragment,{children:[e.jsx(Oe,{state:le}),l&&e.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:l}),e.jsx(Ee,{show:n?.partial===!0,label:"roster partial",title:n?.partial_errors?.join(` +import{r as d,s as T,j as e,L as I,u as te,S as $,B as y,a as fe,b as R,l as xe,c as he,d as be,e as ve,f as je,g as Ne,h as ye,R as ke,i as H,k as we,G as K,m as Ce,n as Se,o as Ae}from"./index-C20tCZFz.js";import{e as X}from"./context-window-Cu9zl36t.js";import{r as J,a as Re}from"./routeHighlight-B30gQO2o.js";import{i as $e,c as O,a as Ie,s as _e,b as se,d as E,e as Me,L as Le}from"./projectOf-CwPPScnJ.js";import{M as ne}from"./constants-DBKWGg29.js";import{P as Pe}from"./PageHeader-D_D-jYn1.js";import{S as Oe,P as Ee}from"./SseIndicator-CeTTAF2S.js";import{f as ae}from"./time-D9v0saHV.js";import{L as ie,i as Q}from"./LiveSessionPeek-jm19JJ4Z.js";import{T as Te}from"./Table-DojZJIvD.js";import{l as Be}from"./agentReads-C0EYRgYm.js";import"./format-fte2CeYD.js";function qe(s){const n=s.indexOf("-");if(n<=0)return!1;const o=s.slice(0,n),l=s.slice(n+1);return!l||!/^[a-z0-9]+$/.test(l)||!(o==="gc"||o==="td"||o==="th"||/^[a-z]{4}$/.test(o))?!1:/[0-9]/.test(l)}function ze(s){const n=s.trim();if(qe(n))return{role:n,sessionId:n};for(let o=n.length-1;o>=0;o--){const l=n.charAt(o);if(l!=="-"&&l!=="_"&&l!=="/")continue;const c=n.slice(o+1);if(c&&/^(?:gc|td|th|[a-z]{4})-[a-z0-9]{1,32}$/.test(c))return{role:n.slice(0,o),sessionId:c}}return{role:n}}const We="in_progress";function De(s){return Ie(_e(s).label)}function Fe(s){const n=O(s.template??"");return n.length>0?n:O(s.session_name??s.id)}function Z(s){const n=s.session.last_active?Date.parse(s.session.last_active):NaN;return Number.isFinite(n)?n:0}function Ue(s,n){const o=new Map;for(const r of n){if(r.status!==We)continue;const i=r.assignee?.trim();if(!i)continue;const{sessionId:m}=ze(i);m&&!o.has(m)&&o.set(m,r)}const l=[];for(const r of s){if(!$e(r))continue;const i=o.get(r.id);l.push({session:r,rig:De(r),worker:Fe(r),...i?{bead:i}:{}})}l.sort((r,i)=>Z(i)-Z(r));const c=new Map;for(const r of l)c.set(r.rig,(c.get(r.rig)??0)+1);const u=Array.from(c,([r,i])=>({rig:r,count:i})).sort((r,i)=>i.count-r.count||r.rig.localeCompare(i.rig));return{workers:l,byRig:u,total:l.length}}function Ve(s){if(s.total===0)return"No workers active right now.";const n=s.total===1?"worker":"workers",o=s.byRig.map(l=>`${l.rig} (${l.count})`).join(", ");return`${s.total} ${n} active across ${o}.`}function Ye({worker:s,accent:n,onPeek:o}){const l=te(),{session:c,rig:u,bead:r}=s,i=n?T(c.state):"neutral";return e.jsx("li",{className:"px-2 py-2 -mx-2 rounded-sm transition-colors duration-150 ease-out-quart hover:bg-surface-tint/60",children:e.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[e.jsxs("div",{className:"min-w-0 text-body text-fg",children:[e.jsxs("button",{type:"button",onClick:()=>o(c.id),className:"group text-left cursor-pointer focus-mark",title:`Open ${u} · ${s.worker} transcript`,children:[e.jsx("span",{className:"font-medium group-hover:text-accent",children:u}),e.jsxs("span",{className:"text-fg-faint","aria-hidden":"true",children:[" ","·"," "]}),e.jsx("span",{className:"text-fg-muted group-hover:text-accent",children:s.worker})]}),r&&e.jsxs(I,{to:`/beads?bead=${encodeURIComponent(r.id)}`,className:"hover:text-accent focus-mark",title:`Open ${r.id}`,children:[e.jsxs("span",{className:"text-fg-faint","aria-hidden":"true",children:[" ","→"," "]}),e.jsx("span",{className:"tnum text-fg-muted",children:r.id}),e.jsxs("span",{className:"text-fg-muted",children:[": ",r.title]})]})]}),e.jsxs("div",{className:"flex items-baseline gap-3 shrink-0",children:[e.jsx($,{tone:i,label:c.state}),e.jsx("span",{className:"tnum text-fg-muted w-10 text-right",children:ae(c.last_active,l)}),e.jsx(y,{size:"sm",tone:"quiet",onClick:()=>o(c.id),children:"Peek"})]})]})})}function Ge(s){return s.running===!0||s.state==="active"||s.state==="running"}function He({beads:s,sessions:n,sessionsLoading:o,sessionsError:l}){const c=d.useMemo(()=>Ue(n,s),[n,s]),u=d.useMemo(()=>Ve(c),[c]),[r,i]=d.useState(null),m=d.useMemo(()=>r?c.workers.find(f=>f.session.id===r)??null:null,[c.workers,r]),k=d.useMemo(()=>c.workers.findIndex(f=>T(f.session.state)==="stuck"),[c.workers]),p=n.length===0,w=l!==null&&p,N=o&&p,_=w||N?"—":c.total;return e.jsxs("section",{className:"mb-10","aria-label":"Workers active",children:[e.jsxs("header",{className:"flex items-baseline justify-between border-b border-rule pb-2 mb-4",children:[e.jsx("h2",{className:"text-headline text-fg",children:"Workers active"}),e.jsx("span",{className:"text-label tnum text-fg-muted",children:_})]}),w?e.jsx("p",{className:"text-body text-fg-muted",role:"status",children:"Worker status unavailable."}):N?e.jsx("p",{className:"text-body text-fg-muted",role:"status",children:"Checking worker status…"}):c.total===0?e.jsx("p",{className:"text-body text-fg-muted",children:"No workers active right now."}):e.jsxs(e.Fragment,{children:[e.jsx("p",{className:"text-body text-fg-muted mb-4",children:u}),e.jsx("ul",{className:"space-y-1",children:c.workers.map((f,C)=>e.jsx(Ye,{worker:f,accent:C===k,onPeek:i},f.session.id))})]}),e.jsx(ne,{open:m!==null,onClose:()=>i(null),title:m?`${m.rig} · ${m.worker}`:"Transcript",caption:m?.bead?e.jsxs(I,{to:`/beads?bead=${encodeURIComponent(m.bead.id)}`,className:"text-fg-muted hover:text-accent focus-mark",title:`Open ${m.bead.id}`,children:[e.jsx("span",{className:"tnum",children:m.bead.id}),e.jsxs("span",{children:[": ",m.bead.title]})]}):"Live transcript from the supervisor's session stream.",widthClass:"max-w-5xl",children:e.jsx(ie,{sessionId:r,stream:m?Ge(m.session):!1,showBadge:!0,showCaption:!0})})]})}function ee(s){return s.session?.name??s.name}function Ke(s){return!s.suspended&&(s.state==="active"||s.state==="running"||s.running===!0)}function Xe(s,n){return Ke(s)||n==="attention"}function P(s){const n=O(s.name);return se(s)?n:`${E(s).label} · ${n}`}const Je=s=>[s.name,s.display_name,s.pool,s.rig,s.provider,s.model].filter(n=>typeof n=="string"&&n.length>0);function ft(){const s=fe(),{data:n,loading:o,error:l,refresh:c}=R("agents",Be),u=R("sessions",xe),r=R("beads:in-flight",()=>he()),i=d.useMemo(()=>n?.items??[],[n]),m=d.useMemo(()=>(u.data?.items??[]).map(t=>t.id).sort(),[u.data]),k=d.useMemo(()=>i.map(t=>t.name).sort(),[i]),p=R(`agent-pending:${k.join(",")}:${m.join(",")}`,()=>be(i,u.data?.items??[])),w=d.useMemo(()=>{const t=new Map;for(const a of u.data?.items??[])a.session_name&&t.set(a.session_name,a.id);return t},[u.data]),N=d.useMemo(()=>{const t=new Map;for(const a of p.data??[])t.set(a.agentName,a);return t},[p.data]),_=d.useMemo(()=>{const t=(p.data??[]).map(g=>({agentName:g.agentName,...g.pending.prompt===void 0?{}:{prompt:g.pending.prompt}})),a=new Map(i.map(g=>[g.name,g]));return ve(i,t).flatMap(g=>{const b=a.get(g.name);return b===void 0?[]:[{need:g,label:P(b),slug:ee(b)}]})},[i,p.data]),f=te(),[C,oe]=d.useState(!0),[M,re]=d.useState(""),[v,B]=d.useState(""),[S,q]=d.useState(null),[z,W]=d.useState(null),[D,F]=d.useState(null),[j,U]=d.useState(null),x=d.useMemo(()=>S===null?null:i.find(t=>t.name===S)??null,[i,S]),V=d.useMemo(()=>{const t=x?.session?.name;return t?w.get(t)??null:null},[x,w]),le=je([K.session,K.bead,"agent."],()=>{c(),r.refresh(),u.refresh()}),ce=d.useMemo(()=>st(i),[i]),h=Ne(),L=d.useCallback(async(t,a)=>{if(!h){U({sessionId:t.sessionId,action:a}),W(null),F(null);try{await ye(t.sessionId,{action:a,request_id:t.pending.request_id}),W(`responded to ${t.agentName}`),await p.refresh()}catch(g){F(g instanceof Error?g.message:"response failed")}finally{U(null)}}},[p,h]),A=d.useMemo(()=>Array.from(new Set(i.filter(t=>!se(t)).map(t=>E(t).label))).sort((t,a)=>t.localeCompare(a)),[i]);d.useEffect(()=>{v!==""&&!A.includes(v)&&B("")},[A,v]);const Y=d.useMemo(()=>{const t=M.trim().toLowerCase();return i.filter(a=>{if(v!==""&&E(a).label!==v)return!1;const g=J(s,"agents",a.name);return C&&!Xe(a,g)?!1:t.length===0?!0:Je(a).some(b=>b.toLowerCase().includes(t))})},[i,v,C,M,s]),de=d.useMemo(()=>t=>Re(J(s,"agents",t.name)),[s]),G=l!==null&&i.length===0,ue=G?"Agent roster unavailable.":i.length===0?"No agents configured.":"No agents match the current search or filter.",me=d.useMemo(()=>[{key:"name",label:"Agent",sortable:!0,sortValue:t=>P(t),render:t=>{const a=Me(t),g=t.display_name&&t.display_name!==t.name?t.display_name:t.provider??t.model??"",b=!t.session,ge=b?`${t.name} — configured but not running; detail will show no live session`:`Open drilldown for ${t.name}`,pe=b?"text-fg-muted":"text-fg";return e.jsxs("div",{className:"min-w-0",children:[e.jsx(I,{to:`/agents/${encodeURIComponent(ee(t))}`,className:`block ${pe} truncate hover:text-accent focus-mark ${a?"font-normal italic":"font-medium"}`,title:ge,children:P(t)}),g&&e.jsx("div",{className:"text-label uppercase tracking-wider text-fg-faint mt-1 truncate",children:g})]})}},{key:"state",label:"State",sortable:!0,sortValue:t=>t.state,render:t=>e.jsx($,{tone:T(t.state),label:t.state,...t.session?.attached?{trailing:"att"}:{},...t.unavailable_reason?{title:`unavailable: ${t.unavailable_reason}`}:{}}),className:"w-32"},{key:"activity",label:"Activity",sortable:!0,sortValue:t=>t.activity??"",render:t=>{const a=N.get(t.name);return a!==void 0?e.jsxs("div",{className:"min-w-0",children:[e.jsx($,{tone:"stuck",label:"needs you"}),e.jsx("p",{className:"mt-1 truncate text-fg-muted",title:a.pending.prompt,children:a.pending.prompt??a.pending.kind})]}):e.jsx("span",{className:"text-fg-muted",children:t.activity??(t.running?"running":"·")})},className:"w-28"},{key:"context",label:"Context",sortable:!0,sortValue:t=>X(t)??-1,align:"right",render:t=>{const a=X(t);if(typeof a!="number")return e.jsx("span",{className:"text-fg-faint",children:"·"});const g=typeof t.context_pct=="number"&&t.context_pct!==a?`gc reports ${t.context_pct}% against ${t.context_window??"?"}-token window; scaled to model's true window`:void 0;return e.jsxs("span",{title:g,className:`tnum ${a>=95?"text-accent font-medium":a>=80?"text-warn font-medium":"text-fg-muted"}`,children:[a,"%"]})},className:"w-24"},{key:"last_active",label:"Last active",sortable:!0,sortValue:t=>t.session?.last_activity??"",render:t=>{const a=t.session?.last_activity;return a?e.jsx("span",{className:"tnum text-fg-muted",children:ae(a,f)}):e.jsx("span",{className:"text-fg-faint tnum",children:"·"})},className:"w-32"},{key:"actions",label:"",render:t=>{if(!t.session)return null;const a=N.get(t.name);return e.jsxs("div",{className:"flex justify-end gap-2",children:[a!==void 0&&e.jsxs(e.Fragment,{children:[h&&e.jsx(ke,{}),e.jsx(y,{size:"sm",tone:"quiet",title:h?H:void 0,disabled:h||j?.sessionId===a.sessionId,onClick:()=>{L(a,"approve")},children:j?.sessionId===a.sessionId&&j.action==="approve"?"Approving":"Approve"}),e.jsx(y,{size:"sm",tone:"quiet",title:h?H:void 0,disabled:h||j?.sessionId===a.sessionId,onClick:()=>{L(a,"deny")},children:j?.sessionId===a.sessionId&&j.action==="deny"?"Denying":"Deny"}),e.jsx(Ze,{command:we(t.name)})]}),e.jsx(y,{size:"sm",tone:"quiet",onClick:()=>q(t.name),children:"Peek"})]})},align:"right",className:"w-80"}],[L,f,N,h,j]);return e.jsxs("section",{children:[e.jsx(Pe,{title:"Agents",synopsis:G?"Agent roster unavailable.":ce,meta:e.jsxs(e.Fragment,{children:[e.jsx(Oe,{state:le}),l&&e.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:l}),e.jsx(Ee,{show:n?.partial===!0,label:"roster partial",title:n?.partial_errors?.join(` `)??"one or more agent backends unavailable"}),e.jsx(y,{size:"sm",onClick:()=>{c()},disabled:o,children:o?"Refreshing":"Refresh"})]})}),e.jsx(Qe,{rows:_}),e.jsx(He,{beads:r.data?.items??[],sessions:u.data?.items??[],sessionsLoading:u.loading,sessionsError:u.error}),e.jsxs("header",{className:"flex items-baseline justify-between border-b border-rule pb-2 mb-4",children:[e.jsx("h2",{className:"text-headline text-fg",children:"Available agents"}),e.jsx("span",{className:"text-label tnum text-fg-muted",children:i.length})]}),e.jsxs("div",{className:"mb-6 space-y-3",children:[e.jsx(Le,{value:M,onChange:re,placeholder:"Search agents by alias, rig, pool, provider",matchCount:Y.length,totalCount:i.length,ariaLabel:"Search agents"}),e.jsxs("div",{className:"flex items-baseline gap-6",children:[e.jsxs("label",{className:"inline-flex items-baseline gap-2 text-label uppercase tracking-wider text-fg-muted cursor-pointer hover:text-fg transition-colors duration-150 ease-out-quart",children:[e.jsx("input",{type:"checkbox",checked:C,onChange:t=>oe(t.target.checked),style:{accentColor:"oklch(var(--fg-muted))"},className:"translate-y-[2px]"}),e.jsx("span",{children:"running"})]}),A.length>1&&e.jsxs("label",{className:"inline-flex items-baseline gap-2 text-label uppercase tracking-wider text-fg-muted",children:[e.jsx("span",{children:"rig"}),e.jsxs("select",{value:v,onChange:t=>B(t.target.value),"aria-label":"Rig filter",className:"text-label uppercase tracking-wider text-fg-muted bg-transparent border-0 focus-mark cursor-pointer hover:text-fg transition-colors duration-150 ease-out-quart",children:[e.jsx("option",{value:"",children:"all rigs"}),A.map(t=>e.jsx("option",{value:t,children:t},t))]})]})]})]}),z&&e.jsx("div",{className:"mb-4 text-body text-fg-muted",role:"status",children:z}),D&&e.jsx("div",{className:"mb-4 text-body text-accent",role:"alert",children:D}),e.jsx(Te,{rows:Y,columns:me,rowKey:t=>t.name,rowProps:de,empty:ue,initialSort:{key:"last_active",dir:"desc"}}),e.jsx(ne,{open:S!==null,onClose:()=>q(null),title:x?.name??S??"Transcript",caption:x&&x.session&&!V?u.loading?"Resolving session…":`No live session matches "${x.session.name}".`:Q(x)?"Live transcript from the supervisor's session stream.":"Snapshot from the supervisor's transcript API.",widthClass:"max-w-5xl",children:e.jsx(ie,{sessionId:V,stream:Q(x),showBadge:!0,showCaption:!0})})]})}function Qe({rows:s}){return s.length===0?null:e.jsxs("section",{"aria-label":"Agents needing you",className:"mb-10",children:[e.jsxs("h2",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:["Needs you (",s.length,")"]}),e.jsx("ol",{className:"mt-3 divide-y divide-rule",children:s.map(({need:n,label:o,slug:l})=>e.jsxs("li",{className:"py-3",children:[e.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[e.jsx(I,{to:`/agents/${encodeURIComponent(l)}`,className:"focus-mark block min-w-0 truncate text-title text-fg hover:text-accent",children:o}),e.jsx($,{tone:Se(n.reason),label:Ce(n.reason)})]}),e.jsx("p",{className:"mt-1 text-body text-fg leading-snug",children:n.detail}),e.jsx("p",{className:"mt-0.5 text-body text-fg-muted leading-snug",children:Ae(n.action)})]},n.name))})]})}function Ze({command:s}){const[n,o]=d.useState("idle"),l=n==="copied"?"Copied":n==="failed"?"Copy failed":"Copy attach";return e.jsx(y,{size:"sm",tone:"quiet",title:s,onClick:()=>{et(s,o)},children:l})}async function et(s,n){try{await navigator.clipboard.writeText(s),n("copied")}catch{n("failed")}}function tt(s){if(s.suspended)return"suspended";switch(s.state){case"active":case"running":return"active";case"detached":return"detached";case"rate-limited":case"rate_limited":case"waiting":return"rate-limited";case"failed":case"closed":case"errored":case"stuck":return"stuck";default:return"idle"}}function st(s){if(s.length===0)return"No agents configured.";const n=new Map;for(const k of s){const p=tt(k);n.set(p,(n.get(p)??0)+1)}const o=[],l=n.get("active")??0,c=n.get("idle")??0,u=n.get("detached")??0,r=n.get("rate-limited")??0,i=n.get("stuck")??0,m=n.get("suspended")??0;return l>0&&o.push(`${l} active`),c>0&&o.push(`${c} idle`),u>0&&o.push(`${u} detached`),r>0&&o.push(`${r} rate-limited`),i>0&&o.push(`${i} stuck`),m>0&&o.push(`${m} suspended`),o.join(", ")+"."}export{ft as AgentsPage,P as agentRowLabel,st as buildAgentSynopsis,Ke as isRunningAgent,Xe as isVisibleUnderRunning,T as stateTone}; diff --git a/internal/api/dashboardspa/dist/assets/AmbientHome-QKhI8-ES.js b/internal/api/dashboardspa/dist/assets/AmbientHome-usE4zKNv.js similarity index 98% rename from internal/api/dashboardspa/dist/assets/AmbientHome-QKhI8-ES.js rename to internal/api/dashboardspa/dist/assets/AmbientHome-usE4zKNv.js index 2e6a886d50..fbc0cb994b 100644 --- a/internal/api/dashboardspa/dist/assets/AmbientHome-QKhI8-ES.js +++ b/internal/api/dashboardspa/dist/assets/AmbientHome-usE4zKNv.js @@ -1 +1 @@ -import{a as j,j as a,r as c,L as h,D as N,E as S,u as M,b as p,F as A,H as y,I as R}from"./index-BFDP6Xwd.js";import{P as f}from"./PageHeader-5RHLpIfH.js";function m(e){return e.phase==="approval"||e.phase==="blocked"}const L={agents:"Agents",beads:"Beads",runs:"Runs",mail:"Mail",activity:"Activity",health:"Health"},$={agents:"/agents",beads:"/beads",runs:"/runs",mail:"/mail",activity:"/activity",health:"/health"};function x(e){return L[e]}function C(e){return $[e]}function E(){const e=j();return e.items.length===0?null:a.jsxs("section",{"aria-labelledby":"attention-summary-title",className:"space-y-3",children:[a.jsx("h2",{id:"attention-summary-title",className:"text-headline font-semibold text-fg",children:"Attention"}),a.jsx("ul",{className:"space-y-2",children:e.topItems.map(t=>a.jsxs("li",{className:"text-body text-fg flex items-baseline gap-3",children:[a.jsx(D,{item:t}),a.jsx("span",{className:`text-label uppercase tracking-wider ${_(t.severity)}`,children:x(t.domain)})]},`${t.domain}:${t.id}`))}),e.overflowByDomain.length>0&&a.jsx("p",{className:"text-label uppercase tracking-wider text-fg-muted",children:e.overflowByDomain.map((t,n)=>a.jsxs(c.Fragment,{children:[n>0&&" · ",a.jsxs(h,{to:C(t.domain),className:"hover:text-fg focus-mark",children:[t.total," more in ",x(t.domain)]})]},t.domain))})]})}function D({item:e}){return e.href===void 0?a.jsx("span",{className:"font-medium",children:e.title}):a.jsx(h,{to:e.href,className:"font-medium hover:text-fg focus-mark",children:e.title})}function _(e){switch(e){case"attention":return"text-accent";case"watch":return"text-warn";case"unavailable":return"text-fg-muted"}}function F(e){return e.external.status!=="unavailable"?e.external.label:e.title}function H(e){const t=encodeURIComponent(e.id),n=e.scope.status==="available"?e.scope:null;if(e.health.status==="available"&&e.health.data.stuckNode.status==="available"){const i=new URLSearchParams;return i.set("node",e.health.data.stuckNode.id),n&&(i.set("scope_kind",n.kind),i.set("scope_ref",n.ref)),`/runs/${t}?${i.toString()}`}if(n){const i=new URLSearchParams;return i.set("scope_kind",n.kind),i.set("scope_ref",n.ref),`/runs/${t}?${i.toString()}`}return`/runs/${t}`}function I(e){switch(e){case"needsOperator":return"needs you";case"stalled":return"stalled";default:return e}}function P({rows:e}){return a.jsx("section",{id:"needs-you",children:a.jsx("ul",{className:"mt-2 transition-opacity duration-150 ease-out-quart motion-reduce:transition-none",style:{opacity:e.length===0?0:1},"aria-live":"polite","data-testid":"concern-region",children:e.map(({lane:t,reason:n})=>a.jsxs("li",{className:"text-body text-fg flex items-baseline gap-3",children:[a.jsx(h,{to:H(t),className:"font-medium hover:text-fg focus-mark","data-testid":`concern-row-${t.id}`,children:F(t)}),a.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:I(n)})]},t.id))})})}const g="gascity:home-intro-dismissed",v="FirstRunNote";function T(){const[e,t]=c.useState(()=>N("localStorage",g,v).status==="found");if(e)return null;const n=()=>{t(!0),S("localStorage",g,"1",v)};return a.jsxs("aside",{className:"mt-6 max-w-[70ch]","data-testid":"first-run-note",children:[a.jsx("p",{className:"text-body text-fg-muted",children:"New here? This page is the ambient home for a Gas City workspace: a calm census of the formula runs in flight. Healthy work stays quiet by design; the page speaks up only when a run needs an operator decision. The full record lives in Agents, Beads, Runs, and Mail above."}),a.jsx("button",{type:"button",onClick:n,className:"mt-2 text-label uppercase tracking-wider text-fg-muted hover:text-fg transition-colors duration-150 ease-out-quart focus-mark",children:"Dismiss"})]})}function O({census:e,waitingCount:t,failingCount:n}){const s=e.unverifiable>0?` (of ${e.knownDenominator} known)`:"",r=n===0?`nothing failing${s}`:`${n} failing${s}`;return a.jsxs("p",{className:"text-title tnum text-fg","data-testid":"phase-census",children:[a.jsxs("span",{children:[e.totalInFlight," in flight"]}),a.jsx("span",{"aria-hidden":"true",className:"mx-2 text-fg-faint",children:"·"}),a.jsxs("span",{children:[t," waiting"]}),a.jsx("span",{"aria-hidden":"true",className:"mx-2 text-fg-faint",children:"·"}),a.jsx("span",{className:n>0?"font-semibold text-fg":"","aria-live":"polite","data-testid":"phase-census-failing",children:r})]})}function B(e){const t=Math.floor(e/6e4);if(t<60)return`${t} min`;const n=Math.floor(t/60);return n<24?`${n}h`:`${Math.floor(n/24)}d`}function q(e){if(e.health.status!=="available")return null;const t=e.health.data.stuckNode;if(t.status!=="available")return null;const n=encodeURIComponent(e.id),i=e.scope.status==="available"?e.scope:null,s=new URLSearchParams;return s.set("node",t.id),i&&(s.set("scope_kind",i.kind),s.set("scope_ref",i.ref)),`/runs/${n}?${s.toString()}`}function U(e){return e.external.status!=="unavailable"?e.external.label:e.title}function G(e){return m(e)?"has been waiting on your decision for":"has waited on a review verdict for"}function K({topConcern:e}){const{lane:t,ageMs:n}=e,i=U(t),s=q(t),r=G(t),o=B(n);return a.jsxs("p",{className:"text-body text-fg max-w-[70ch] leading-relaxed","data-testid":"status-sentence",children:[s===null?a.jsx("span",{"data-testid":"status-sentence-token",children:i}):a.jsx(h,{to:s,className:"text-accent font-semibold focus-mark","data-testid":"status-sentence-token",children:i})," ",r," ",o,"."]})}const V="/favicon-calm.svg",W="/favicon-alert.svg",Y=2;function Q(e){const t=document.getElementById("favicon");t instanceof HTMLLinkElement&&(t.href=`${e}?v=${Date.now()}`)}function z({failing:e,cycleKey:t}){const n=c.useRef("calm"),i=c.useRef(0),s=c.useRef(null);c.useEffect(()=>{if(s.current===t)return;s.current=t;const r=n.current,o=e>0?"alert":"calm";if(o===r){i.current=0;return}i.current+=1,!(i.current=b.stalled?"stalled":e>=b.warning?"warning":"fresh"}function Z(e){const t=M();return c.useMemo(()=>{const n=new Map,i=[];for(const s of e){const r=J(s),o=s.health.status==="available"&&s.health.data.phaseConfidence==="known";if(r===null){n.set(s.id,{tier:"unknown",ageMs:0,isStalled:!1});continue}const l=Math.max(0,t-r);if(!o){n.set(s.id,{tier:"unknown",ageMs:l,isStalled:!1});continue}const d=X(l),u=d==="stalled";n.set(s.id,{tier:d,ageMs:l,isStalled:u}),u&&i.push({id:s.id,ageMs:l})}return i.sort((s,r)=>r.ageMs-s.ageMs),{byLane:n,clientStalledLaneIds:i.map(s=>s.id)}},[e,t])}function ee(e,t){const n=[];for(const s of e){if(s.health.status!=="available"||!(s.health.data.phaseConfidence==="known"))continue;const o=t.byLane.get(s.id)?.ageMs??0;s.health.data.thrashingDetected?n.push({lane:s,ageMs:o,priority:2}):t.byLane.get(s.id)?.isStalled&&n.push({lane:s,ageMs:o,priority:1})}if(n.length===0)return;n.sort((s,r)=>r.priority-s.priority||r.ageMs-s.ageMs);const i=n[0];return{lane:i.lane,ageMs:i.ageMs}}function te(e,t,n){const i=[];for(const s of e){if(s.id===n)continue;if(m(s)){i.push({lane:s,reason:"needsOperator"});continue}if(s.health.status!=="available")continue;const r=s.health.data;r.phaseConfidence==="known"&&(r.thrashingDetected||t.byLane.get(s.id)?.isStalled)&&i.push({lane:s,reason:"stalled"})}return i}function se(e){let t=0;for(const n of e)m(n)&&(t+=1);return t}function ne(e){return e===void 0||e.status==="error"?null:{source:e,summary:e.data}}function ae({fresh:e,cityName:t,cycleKey:n,workInProgress:i}){const{summary:s}=e,r=c.useMemo(()=>[...s.lanes,...s.blockedLanes],[s.lanes,s.blockedLanes]),o=Z(r),l=c.useMemo(()=>ee(r,o),[r,o]),d=c.useMemo(()=>te(r,o,l?.lane.id),[r,o,l]),u=s.census.status!=="available"?0:s.census.data.thrashing+o.clientStalledLaneIds.length;z({failing:u,cycleKey:n});const w=i.status==="available"?`, ${i.value} in progress`:"",k=t!==null?`${t}, ${s.totalActive} active${w}`:null;return a.jsxs("section",{children:[a.jsx(f,{title:"Home",synopsis:k}),a.jsx(T,{}),s.census.status!=="available"?a.jsxs("p",{className:"mt-6 text-body text-fg-muted max-w-[70ch]",role:"alert","data-testid":"census-unavailable",children:["Census unavailable: ",s.census.error,"."]}):a.jsxs("div",{className:"mt-6 space-y-6",children:[a.jsx(E,{}),a.jsxs("div",{className:"space-y-4",children:[a.jsx(O,{census:s.census.data,waitingCount:se(r),failingCount:u}),l!==void 0&&a.jsx(K,{topConcern:l}),a.jsx(P,{rows:d})]})]})]})}function ce(){const e=y(),{data:t,loading:n,error:i}=p(`runs:summary:${e??"no-city"}`,A),s=p(`home:work:${e??"no-city"}`,ie),r=ne(t),o=r?.source.fetchedAt??"pre-snapshot";return t===void 0&&n?a.jsxs("section",{children:[a.jsx(f,{title:"Home",synopsis:null}),a.jsx("p",{className:"mt-6 text-body text-fg-muted",children:"Loading…"})]}):t===void 0&&i!==null?a.jsxs("section",{children:[a.jsx(f,{title:"Home",synopsis:null}),a.jsx("p",{className:"mt-6 text-body text-accent",role:"alert","data-testid":"snapshot-error",children:i})]}):r===null?a.jsxs("section",{children:[a.jsx(f,{title:"Home",synopsis:null}),a.jsx("p",{className:"mt-6 text-body text-accent",role:"alert","data-testid":"runs-source-error",children:"Run data is unavailable."})]}):a.jsx(ae,{fresh:r,cityName:e,cycleKey:o,workInProgress:s.data??{status:"unavailable",source:"work",error:"loading"}})}async function ie(){const e=y();if(e===null)return{status:"unavailable",source:"work",error:"active city unavailable"};try{return{status:"available",value:(await R().cityStatus(e)).work.in_progress}}catch(t){return{status:"unavailable",source:"work",error:t instanceof Error?t.message:"work unavailable"}}}export{ce as AmbientHomePage}; +import{a as j,j as a,r as c,L as h,D as N,E as S,u as M,b as p,F as A,H as y,I as R}from"./index-C20tCZFz.js";import{P as f}from"./PageHeader-D_D-jYn1.js";function m(e){return e.phase==="approval"||e.phase==="blocked"}const L={agents:"Agents",beads:"Beads",runs:"Runs",mail:"Mail",activity:"Activity",health:"Health"},$={agents:"/agents",beads:"/beads",runs:"/runs",mail:"/mail",activity:"/activity",health:"/health"};function x(e){return L[e]}function C(e){return $[e]}function E(){const e=j();return e.items.length===0?null:a.jsxs("section",{"aria-labelledby":"attention-summary-title",className:"space-y-3",children:[a.jsx("h2",{id:"attention-summary-title",className:"text-headline font-semibold text-fg",children:"Attention"}),a.jsx("ul",{className:"space-y-2",children:e.topItems.map(t=>a.jsxs("li",{className:"text-body text-fg flex items-baseline gap-3",children:[a.jsx(D,{item:t}),a.jsx("span",{className:`text-label uppercase tracking-wider ${_(t.severity)}`,children:x(t.domain)})]},`${t.domain}:${t.id}`))}),e.overflowByDomain.length>0&&a.jsx("p",{className:"text-label uppercase tracking-wider text-fg-muted",children:e.overflowByDomain.map((t,n)=>a.jsxs(c.Fragment,{children:[n>0&&" · ",a.jsxs(h,{to:C(t.domain),className:"hover:text-fg focus-mark",children:[t.total," more in ",x(t.domain)]})]},t.domain))})]})}function D({item:e}){return e.href===void 0?a.jsx("span",{className:"font-medium",children:e.title}):a.jsx(h,{to:e.href,className:"font-medium hover:text-fg focus-mark",children:e.title})}function _(e){switch(e){case"attention":return"text-accent";case"watch":return"text-warn";case"unavailable":return"text-fg-muted"}}function F(e){return e.external.status!=="unavailable"?e.external.label:e.title}function H(e){const t=encodeURIComponent(e.id),n=e.scope.status==="available"?e.scope:null;if(e.health.status==="available"&&e.health.data.stuckNode.status==="available"){const i=new URLSearchParams;return i.set("node",e.health.data.stuckNode.id),n&&(i.set("scope_kind",n.kind),i.set("scope_ref",n.ref)),`/runs/${t}?${i.toString()}`}if(n){const i=new URLSearchParams;return i.set("scope_kind",n.kind),i.set("scope_ref",n.ref),`/runs/${t}?${i.toString()}`}return`/runs/${t}`}function I(e){switch(e){case"needsOperator":return"needs you";case"stalled":return"stalled";default:return e}}function P({rows:e}){return a.jsx("section",{id:"needs-you",children:a.jsx("ul",{className:"mt-2 transition-opacity duration-150 ease-out-quart motion-reduce:transition-none",style:{opacity:e.length===0?0:1},"aria-live":"polite","data-testid":"concern-region",children:e.map(({lane:t,reason:n})=>a.jsxs("li",{className:"text-body text-fg flex items-baseline gap-3",children:[a.jsx(h,{to:H(t),className:"font-medium hover:text-fg focus-mark","data-testid":`concern-row-${t.id}`,children:F(t)}),a.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:I(n)})]},t.id))})})}const g="gascity:home-intro-dismissed",v="FirstRunNote";function T(){const[e,t]=c.useState(()=>N("localStorage",g,v).status==="found");if(e)return null;const n=()=>{t(!0),S("localStorage",g,"1",v)};return a.jsxs("aside",{className:"mt-6 max-w-[70ch]","data-testid":"first-run-note",children:[a.jsx("p",{className:"text-body text-fg-muted",children:"New here? This page is the ambient home for a Gas City workspace: a calm census of the formula runs in flight. Healthy work stays quiet by design; the page speaks up only when a run needs an operator decision. The full record lives in Agents, Beads, Runs, and Mail above."}),a.jsx("button",{type:"button",onClick:n,className:"mt-2 text-label uppercase tracking-wider text-fg-muted hover:text-fg transition-colors duration-150 ease-out-quart focus-mark",children:"Dismiss"})]})}function O({census:e,waitingCount:t,failingCount:n}){const s=e.unverifiable>0?` (of ${e.knownDenominator} known)`:"",r=n===0?`nothing failing${s}`:`${n} failing${s}`;return a.jsxs("p",{className:"text-title tnum text-fg","data-testid":"phase-census",children:[a.jsxs("span",{children:[e.totalInFlight," in flight"]}),a.jsx("span",{"aria-hidden":"true",className:"mx-2 text-fg-faint",children:"·"}),a.jsxs("span",{children:[t," waiting"]}),a.jsx("span",{"aria-hidden":"true",className:"mx-2 text-fg-faint",children:"·"}),a.jsx("span",{className:n>0?"font-semibold text-fg":"","aria-live":"polite","data-testid":"phase-census-failing",children:r})]})}function B(e){const t=Math.floor(e/6e4);if(t<60)return`${t} min`;const n=Math.floor(t/60);return n<24?`${n}h`:`${Math.floor(n/24)}d`}function q(e){if(e.health.status!=="available")return null;const t=e.health.data.stuckNode;if(t.status!=="available")return null;const n=encodeURIComponent(e.id),i=e.scope.status==="available"?e.scope:null,s=new URLSearchParams;return s.set("node",t.id),i&&(s.set("scope_kind",i.kind),s.set("scope_ref",i.ref)),`/runs/${n}?${s.toString()}`}function U(e){return e.external.status!=="unavailable"?e.external.label:e.title}function G(e){return m(e)?"has been waiting on your decision for":"has waited on a review verdict for"}function K({topConcern:e}){const{lane:t,ageMs:n}=e,i=U(t),s=q(t),r=G(t),o=B(n);return a.jsxs("p",{className:"text-body text-fg max-w-[70ch] leading-relaxed","data-testid":"status-sentence",children:[s===null?a.jsx("span",{"data-testid":"status-sentence-token",children:i}):a.jsx(h,{to:s,className:"text-accent font-semibold focus-mark","data-testid":"status-sentence-token",children:i})," ",r," ",o,"."]})}const V="/favicon-calm.svg",W="/favicon-alert.svg",Y=2;function Q(e){const t=document.getElementById("favicon");t instanceof HTMLLinkElement&&(t.href=`${e}?v=${Date.now()}`)}function z({failing:e,cycleKey:t}){const n=c.useRef("calm"),i=c.useRef(0),s=c.useRef(null);c.useEffect(()=>{if(s.current===t)return;s.current=t;const r=n.current,o=e>0?"alert":"calm";if(o===r){i.current=0;return}i.current+=1,!(i.current=b.stalled?"stalled":e>=b.warning?"warning":"fresh"}function Z(e){const t=M();return c.useMemo(()=>{const n=new Map,i=[];for(const s of e){const r=J(s),o=s.health.status==="available"&&s.health.data.phaseConfidence==="known";if(r===null){n.set(s.id,{tier:"unknown",ageMs:0,isStalled:!1});continue}const l=Math.max(0,t-r);if(!o){n.set(s.id,{tier:"unknown",ageMs:l,isStalled:!1});continue}const d=X(l),u=d==="stalled";n.set(s.id,{tier:d,ageMs:l,isStalled:u}),u&&i.push({id:s.id,ageMs:l})}return i.sort((s,r)=>r.ageMs-s.ageMs),{byLane:n,clientStalledLaneIds:i.map(s=>s.id)}},[e,t])}function ee(e,t){const n=[];for(const s of e){if(s.health.status!=="available"||!(s.health.data.phaseConfidence==="known"))continue;const o=t.byLane.get(s.id)?.ageMs??0;s.health.data.thrashingDetected?n.push({lane:s,ageMs:o,priority:2}):t.byLane.get(s.id)?.isStalled&&n.push({lane:s,ageMs:o,priority:1})}if(n.length===0)return;n.sort((s,r)=>r.priority-s.priority||r.ageMs-s.ageMs);const i=n[0];return{lane:i.lane,ageMs:i.ageMs}}function te(e,t,n){const i=[];for(const s of e){if(s.id===n)continue;if(m(s)){i.push({lane:s,reason:"needsOperator"});continue}if(s.health.status!=="available")continue;const r=s.health.data;r.phaseConfidence==="known"&&(r.thrashingDetected||t.byLane.get(s.id)?.isStalled)&&i.push({lane:s,reason:"stalled"})}return i}function se(e){let t=0;for(const n of e)m(n)&&(t+=1);return t}function ne(e){return e===void 0||e.status==="error"?null:{source:e,summary:e.data}}function ae({fresh:e,cityName:t,cycleKey:n,workInProgress:i}){const{summary:s}=e,r=c.useMemo(()=>[...s.lanes,...s.blockedLanes],[s.lanes,s.blockedLanes]),o=Z(r),l=c.useMemo(()=>ee(r,o),[r,o]),d=c.useMemo(()=>te(r,o,l?.lane.id),[r,o,l]),u=s.census.status!=="available"?0:s.census.data.thrashing+o.clientStalledLaneIds.length;z({failing:u,cycleKey:n});const w=i.status==="available"?`, ${i.value} in progress`:"",k=t!==null?`${t}, ${s.totalActive} active${w}`:null;return a.jsxs("section",{children:[a.jsx(f,{title:"Home",synopsis:k}),a.jsx(T,{}),s.census.status!=="available"?a.jsxs("p",{className:"mt-6 text-body text-fg-muted max-w-[70ch]",role:"alert","data-testid":"census-unavailable",children:["Census unavailable: ",s.census.error,"."]}):a.jsxs("div",{className:"mt-6 space-y-6",children:[a.jsx(E,{}),a.jsxs("div",{className:"space-y-4",children:[a.jsx(O,{census:s.census.data,waitingCount:se(r),failingCount:u}),l!==void 0&&a.jsx(K,{topConcern:l}),a.jsx(P,{rows:d})]})]})]})}function ce(){const e=y(),{data:t,loading:n,error:i}=p(`runs:summary:${e??"no-city"}`,A),s=p(`home:work:${e??"no-city"}`,ie),r=ne(t),o=r?.source.fetchedAt??"pre-snapshot";return t===void 0&&n?a.jsxs("section",{children:[a.jsx(f,{title:"Home",synopsis:null}),a.jsx("p",{className:"mt-6 text-body text-fg-muted",children:"Loading…"})]}):t===void 0&&i!==null?a.jsxs("section",{children:[a.jsx(f,{title:"Home",synopsis:null}),a.jsx("p",{className:"mt-6 text-body text-accent",role:"alert","data-testid":"snapshot-error",children:i})]}):r===null?a.jsxs("section",{children:[a.jsx(f,{title:"Home",synopsis:null}),a.jsx("p",{className:"mt-6 text-body text-accent",role:"alert","data-testid":"runs-source-error",children:"Run data is unavailable."})]}):a.jsx(ae,{fresh:r,cityName:e,cycleKey:o,workInProgress:s.data??{status:"unavailable",source:"work",error:"loading"}})}async function ie(){const e=y();if(e===null)return{status:"unavailable",source:"work",error:"active city unavailable"};try{return{status:"available",value:(await R().cityStatus(e)).work.in_progress}}catch(t){return{status:"unavailable",source:"work",error:t instanceof Error?t.message:"work unavailable"}}}export{ce as AmbientHomePage}; diff --git a/internal/api/dashboardspa/dist/assets/BeadDetailModal-BKOlUSQL.js b/internal/api/dashboardspa/dist/assets/BeadDetailModal-BtVrX_Fu.js similarity index 99% rename from internal/api/dashboardspa/dist/assets/BeadDetailModal-BKOlUSQL.js rename to internal/api/dashboardspa/dist/assets/BeadDetailModal-BtVrX_Fu.js index a103168a98..cbed6162b6 100644 --- a/internal/api/dashboardspa/dist/assets/BeadDetailModal-BKOlUSQL.js +++ b/internal/api/dashboardspa/dist/assets/BeadDetailModal-BtVrX_Fu.js @@ -1 +1 @@ -import{r as h,u as H,a0 as K,a1 as O,J as V,I as E,a2 as q,z as W,j as n,S as Y,a3 as Z,L as J,B as X}from"./index-BFDP6Xwd.js";import{f as Q}from"./format-fte2CeYD.js";import{F as x}from"./Field-BpdGqWpv.js";import{a as P,L as ee}from"./LiveSessionPeek-Cjv3DcC3.js";import{M as U}from"./constants-DOaI3lZl.js";import{f as D}from"./time-D9v0saHV.js";function te(e,t){if(e.length===0||t.length===0)return null;const s=t.filter(r=>r.state==="active");return F(e,s)??F(e,t)}function F(e,t){for(const s of t)if(se(s,e))return s;return null}function se(e,t){return e.alias===t||e.pool===t||e.alias!==void 0&&A(e.alias,["/","."])===t||e.session_name!==void 0&&A(e.session_name,["__","--"])===t}function A(e,t){let s=-1,r=0;for(const i of t){const l=e.lastIndexOf(i);l>s&&(s=l,r=i.length)}return s<0?e:e.slice(s+r)}const ne=/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;function re(e,t,s){return`${e}:${s}:${t}`}function b(e,t,s){e?.(t,s)}const ie=/^pr\/(\d{1,9})$/,le=/^issue\/(\d{1,9})$/;function oe(e){const t=e.trim();if(t.length===0)return{ok:!1,error:"empty ref"};const s=ie.exec(t);if(s?.[1])return{ok:!0,type:"github_pr",value:s[1]};const r=le.exec(t);return r?.[1]?{ok:!0,type:"github_issue",value:r[1]}:ne.test(t)?{ok:!0,type:"bead",value:t}:{ok:!1,error:"unrecognised ref"}}function M(e){if(typeof e!="string")return null;const t=e.trim();return/^https?:\/\//i.test(t)?t:null}function v(e,t,s){return re(e,t,s)}function ae(e){return{key:v("bead",e.id,e.scope),type:"bead",ref:e.id}}function _(e,t){const s=e.stats.get(t);if(s)return s;const r={relation:t,resolved:0,unresolved:0,nCandidates:0};return e.stats.set(t,r),r}function y(e,t){e.nodesByKey.has(t.key)||(e.nodesByKey.set(t.key,t),e.view.nodes.push(t))}function k(e,t,s,r,i,l){e.view.edges.push({from:t,to:s,relation:r,provenance:i,resolved:l})}function N(e,t,s,r,i){const l=ae(s);y(e,{...l,title:s.title,status:s.status,url:null,fetchedAt:i,unresolved:!1}),k(e,t,l.key,r,"supervisor",!0),_(e,r).resolved+=1,b(e.recorder,r,"resolved")}function ue(e,t){return{focus:e,nodes:[],edges:[],stats:[],partial:!1,generatedAt:t,asOf:null}}function $(e,t){return e===null?t:t===null||Date.parse(e)<=Date.parse(t)?e:t}function ce(e,t,s={}){const i=(s.now??(()=>new Date))().toISOString(),l=s.supervisorFetchedAt??null,u=s.githubFetchedAt??null,a=de(e,t),o=ue(a.focus,i);o.partial=s.partial??!1;const c={view:o,nodesByKey:new Map,stats:new Map,recorder:s.recorder??(()=>{})};if(y(c,a.focusNode),!a.focusResolved)return o.partial=!0,L(c,l,u),o;const f=a.focusNode.key;if(a.beadFocus)for(const m of a.beads)pe(c,m,f,e,l,u);else for(const m of a.beads)N(c,f,m,"bead",l);return L(c,l,u),o}function de(e,t){if(t.type==="github_pr"||t.type==="github_issue"){const u=t.type==="github_pr"?"github_pr":"github_issue",a=t.type==="github_pr"?`pr/${t.value}`:`issue/${t.value}`,c=(t.type==="github_pr"?e.beadsForPr.get(t.value)??[]:e.beadsForIssue.get(t.value)??[]).map(m=>e.beads.get(m)).filter(m=>m!==void 0),f={key:v(u,t.value,"github"),type:u,ref:a};return{focus:f,focusNode:{...f,title:null,status:null,url:null,fetchedAt:null,unresolved:c.length===0,...c.length>1?{candidateCount:c.length}:{}},beads:c,focusResolved:c.length>0,beadFocus:!1}}const s=e.beads.get(t.value)??e.allBeads.get(t.value);if(s!==void 0){const u=fe(s);return{focus:u,focusNode:{...u,title:s.title,status:s.status,url:null,fetchedAt:null,unresolved:!1},beads:s.superseded?[]:[s],focusResolved:!0,beadFocus:!0}}const r=e.beadsForSession.get(t.value)??[],i=e.sessions.has(t.value);if(r.length>0||i){const u={key:v("session",t.value,"session"),type:"session",ref:t.value},a=r.map(c=>e.beads.get(c)).filter(c=>c!==void 0),o=i||a.length>0;return{focus:u,focusNode:{...u,title:e.sessions.get(t.value)?.title??null,status:e.sessions.get(t.value)?.state??null,url:null,fetchedAt:null,unresolved:!o},beads:a,focusResolved:o,beadFocus:!1}}const l={key:v("bead",t.value,"unknown"),type:"bead",ref:t.value};return{focus:l,focusNode:{...l,title:null,status:null,url:null,fetchedAt:null,unresolved:!0},beads:[],focusResolved:!1,beadFocus:!0}}function fe(e){return{key:v("bead",e.id,e.scope),type:"bead",ref:e.id}}function pe(e,t,s,r,i,l){if(t.parentBeadId){const a=r.beads.get(t.parentBeadId);a?N(e,s,a,"parent",i):me(e,s,t.parentBeadId,"parent")}const u=(r.childrenOf.get(t.id)??[]).filter(a=>a!==t.id);for(const a of u){const o=r.beads.get(a);o&&N(e,s,o,"child",i)}if(t.moleculeId){const a=(r.membersOfMolecule.get(t.moleculeId)??[]).filter(o=>o!==t.id&&o!==t.moleculeId);if(t.moleculeId!==t.id){const o=r.beads.get(t.moleculeId);o&&N(e,s,o,"molecule",i)}for(const o of a){const c=r.beads.get(o);c&&N(e,s,c,"molecule",i)}}if(t.prNumber&&B(e,s,"github_pr",`pr/${t.prNumber}`,t.prNumber,M(t.prUrl),"pr","supervisor",l),t.issueNumber&&B(e,s,"github_issue",`issue/${t.issueNumber}`,t.issueNumber,M(t.issueUrl),"issue","supervisor",l),t.sessionId){const a=r.sessions.get(t.sessionId),o={key:v("session",t.sessionId,"session"),type:"session",ref:t.sessionId};a?(y(e,{...o,title:a.title??a.alias??t.sessionName??null,status:a.state??null,url:null,fetchedAt:i,unresolved:!1}),k(e,s,o.key,"session","supervisor",!0),_(e,"session").resolved+=1,b(e.recorder,"session","resolved")):(y(e,{...o,title:t.sessionName??null,status:null,url:null,fetchedAt:i,unresolved:!0}),k(e,s,o.key,"session","supervisor",!1),_(e,"session").unresolved+=1,b(e.recorder,"session","unresolved"))}}function B(e,t,s,r,i,l,u,a,o){const c=v(s,i,"github");y(e,{key:c,type:s,ref:r,title:null,status:null,url:l,fetchedAt:o,unresolved:!0}),k(e,t,c,u,a,!1),_(e,u).unresolved+=1,b(e.recorder,u,"unresolved")}function me(e,t,s,r){const i=v("bead",s,"unknown");y(e,{key:i,type:"bead",ref:s,title:null,status:null,url:null,fetchedAt:null,unresolved:!0}),k(e,t,i,r,"supervisor",!1),_(e,r).unresolved+=1,b(e.recorder,r,"unresolved")}function L(e,t,s){e.view.stats=[...e.stats.values()].sort((i,l)=>i.relation.localeCompare(l.relation));let r=null;for(const i of e.view.nodes)r=$(r,i.fetchedAt);e.view.asOf=r??$(t,s)}function p(e,t){const s=e.metadata?.[t];if(typeof s=="string"){const r=s.trim();return r.length>0?r:void 0}if(typeof s=="number"&&Number.isFinite(s))return String(s)}function he(e,t){const s=e.metadata?.[t];if(typeof s=="number"&&Number.isInteger(s)&&s>=0)return s;if(typeof s=="string"&&/^\d+$/.test(s.trim()))return Number.parseInt(s.trim(),10)}const ge=["gc.scope_ref","scope_ref","scope_id"],xe=["gc.scope_kind","scope_kind"];function ve(e,t){let s;for(const i of ge){const l=p(e,i);if(l!==void 0){s=l;break}}let r;for(const i of xe){const l=p(e,i);if(l!==void 0){r=l;break}}return s===void 0?`city:${t}`:`${r??"rig"}:${s}`}const ye=/^github-pr:[^/]+\/[^/]+\/(\d+)$/,je=/\/(?:pull\/)?(\d+)(?:[/?#]|$)/;function Ne(e){const t=p(e,"evidence.pr_url"),s=p(e,"evidence.pr_number"),r=p(e,"evidence.artifact_path"),i=p(e,"pr_review.pr_number"),l=p(e,"pr_review.pr_url"),u=r?.match(ye),a=t?.match(je),o=s??u?.[1]??a?.[1]??i??void 0,c=t??l??void 0,f={};return o!==void 0&&(f.prNumber=o),c!==void 0&&(f.prUrl=c),f}function be(e,t){const{prNumber:s,prUrl:r}=Ne(e),i={id:e.id,title:e.title,status:e.status,scope:ve(e,t),superseded:!1},l={parentBeadId:p(e,"gc.parent_bead_id"),rootBeadId:p(e,"gc.root_bead_id"),moleculeId:p(e,"molecule_id"),prNumber:s,prUrl:r,issueNumber:p(e,"bugflow.github_issue_number")??p(e,"design_review.github_issue_number"),issueUrl:p(e,"bugflow.github_issue_url")??p(e,"design_review.github_issue_url"),sessionId:p(e,"session_id"),sessionName:p(e,"session_name"),stepId:p(e,"gc.step_id"),attempt:he(e,"gc.attempt")};for(const[u,a]of Object.entries(l))a!==void 0&&Object.assign(i,{[u]:a});return i}function T(e){return`${e.moleculeId}\0${e.stepId}`}function _e(e){const t=new Map;for(const s of e){if(s.moleculeId===void 0||s.stepId===void 0||s.attempt===void 0)continue;const r=T(s),i=t.get(r);(i===void 0||s.attempt>i)&&t.set(r,s.attempt)}for(const s of e){if(s.moleculeId===void 0||s.stepId===void 0||s.attempt===void 0)continue;const r=t.get(T(s));r!==void 0&&s.attemptbe(d,s));_e(r);const i=new Map,l=new Map,u=new Map,a=new Map,o=new Map,c=new Map,f=new Map;for(const d of r)i.set(d.id,d),!d.superseded&&(l.set(d.id,d),d.parentBeadId&&j(u,d.parentBeadId,d.id),d.moleculeId&&j(a,d.moleculeId,d.id),d.prNumber&&j(o,d.prNumber,d.id),d.issueNumber&&j(c,d.issueNumber,d.id),d.sessionId&&j(f,d.sessionId,d.id));const m=new Map;for(const d of t)m.set(d.id,d);return{beads:l,allBeads:i,childrenOf:u,membersOfMolecule:a,beadsForPr:o,beadsForIssue:c,beadsForSession:f,sessions:m}}function we(e,t,s=null){const[r,i]=h.useState(s),[l,u]=h.useState(!1),[a,o]=h.useState(null),[c,f]=h.useState(!1),m=H();return h.useEffect(()=>{if(!e||!t)return;if(s&&s.id===t&&s.description!==void 0){i(s),o(null),f(!1);return}i(s?.id===t?s:null),u(!0),o(null),f(!1);let d=!1;return(async()=>{try{const g=await K(t);d||i(g)}catch(g){if(d)return;g instanceof O&&g.status===404?f(!0):o(Se(g))}finally{d||u(!1)}})(),()=>{d=!0}},[e,t,s]),{bead:r,loading:l,error:a,notFound:c,now:m}}function Se(e){return e instanceof O?e.status===void 0?e.message:`${e.status} ${e.message}`:e instanceof Error?e.message:"fetch failed"}function Ie(e){return e.partial===!0||(e.partial_errors?.length??0)>0||(e.next_cursor?.length??0)>0}function Re(e,t){return Ie(e)||typeof e.total=="number"&&e.total>t}const Ee=1e3;async function Fe(e){const t=oe(e);if(!t.ok)throw new Error(t.error);const s=V("load supervisor entity links"),r=new Date().toISOString(),i=await E().listBeads(s,{limit:Ee}),l=Ae(i.items??[]);let u=Re(i,l.length),a=[];try{const c=await E().listSessions(s);a=q(c),u||=$e(c)}catch{u=!0}const o=ke(l,a,s);return ce(o,t,{partial:u,supervisorFetchedAt:r,githubFetchedAt:null})}function Ae(e){return e.map(Me)}function Me(e){const t={id:e.id,title:e.title,status:e.status,issue_type:e.issue_type,priority:e.priority??null,created_at:e.created_at};return e.description!==void 0&&(t.description=e.description),e.assignee!==void 0&&(t.assignee=e.assignee),Array.isArray(e.labels)&&(t.labels=e.labels),e.metadata!==void 0&&(t.metadata=e.metadata),e.ref!==void 0&&(t.ref=e.ref),e.parent!==void 0&&(t.parent=e.parent),e.from!==void 0&&(t.from=e.from),e.ephemeral!==void 0&&(t.ephemeral=e.ephemeral),e.needs!==void 0&&(t.needs=e.needs),e.dependencies!==void 0&&(t.dependencies=e.dependencies),e.updated_at!==void 0&&(t.updated_at=e.updated_at),t}function $e(e){return e.partial===!0||(e.partial_errors?.length??0)>0}function Be(e){const[t,s]=h.useState(null),[r,i]=h.useState(!1),[l,u]=h.useState(null);return h.useEffect(()=>{if(e===null||e.length===0){s(null),u(null),i(!1);return}let a=!1;return i(!0),u(null),(async()=>{try{const o=await Fe(e);a||s(o)}catch(o){if(a)return;u(W(o,"related entities failed")),s(null)}finally{a||i(!1)}})(),()=>{a=!0}},[e]),{view:t,loading:r,error:l}}function Le(e){const t=e.metadata;if(!t)return{};const s={};return t["gc.kind"]&&(s.kind=t["gc.kind"]),t["gc.source_bead_id"]&&(s.originBeadId=t["gc.source_bead_id"]),t["gc.formula_contract"]&&(s.formulaContract=t["gc.formula_contract"]),t["gc.run_target"]?s.runTarget=t["gc.run_target"]:t["gc.routed_to"]&&(s.runTarget=t["gc.routed_to"]),s}function Te(e,t){return t.kind==="run"?"template":e.issue_type==="molecule"?"wisp":"work"}function Ce({bead:e}){const t=Le(e),s=Te(e,t);return n.jsxs("div",{className:"space-y-8",children:[s==="template"&&n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:"Formula template"}),n.jsxs("p",{className:"text-body text-fg-muted max-w-prose",children:["This bead is a recipe, not actionable work. Every"," ",e.ref?n.jsx("code",{className:"text-fg-muted",children:e.ref}):"wisp"," instance is instantiated from this template. The ",n.jsx("span",{className:"text-fg-muted",children:"in_progress"})," ","status is the gc-system convention for ",'"',"available for instantiation",'"'," — do not act on it, nudge it, or close it."]})]}),s==="wisp"&&n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:"Formula instance"}),n.jsxs("p",{className:"text-body text-fg-muted max-w-prose",children:["One run of the"," ",e.title?n.jsx("code",{className:"text-fg-muted",children:e.title}):"formula"," recipe."]})]}),n.jsxs("dl",{className:"grid grid-cols-2 sm:grid-cols-4 gap-x-8 gap-y-5",children:[n.jsx(x,{label:"Status",children:n.jsx(Y,{tone:Z(e.status),label:e.status})}),n.jsx(x,{label:"Type",children:e.issue_type}),n.jsx(x,{label:"Assignee",children:e.assignee||"·"}),n.jsx(x,{label:"Created",children:n.jsx("span",{className:"tnum",children:Q(e.created_at)})})]}),s==="template"&&(t.formulaContract||t.originBeadId||t.runTarget)&&n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:"Template origin"}),n.jsx("p",{className:"text-body text-fg-muted max-w-prose mb-4",children:"Where this formula came from, kept for traceability. The origin bead and target may be stale; the formula itself is now used wherever the pool dispatches it."}),n.jsxs("dl",{className:"grid grid-cols-2 sm:grid-cols-3 gap-x-8 gap-y-3",children:[t.formulaContract&&n.jsx(x,{label:"Contract",children:n.jsx("code",{className:"text-fg-muted",children:t.formulaContract})}),e.ref&&n.jsx(x,{label:"Ref",children:n.jsx("code",{className:"text-fg-muted",children:e.ref})}),t.originBeadId&&n.jsx(x,{label:"Origin bead",children:n.jsx("code",{className:"text-fg-muted",children:t.originBeadId})}),t.runTarget&&n.jsx(x,{label:"Origin target",children:n.jsx("span",{className:"text-fg-muted truncate",title:t.runTarget,children:t.runTarget})})]})]}),Array.isArray(e.labels)&&e.labels.length>0&&n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:"Labels"}),n.jsx("div",{className:"flex flex-wrap gap-x-3 gap-y-1",children:e.labels.map(r=>n.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:r},r))})]}),n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:s==="template"?"Recipe":"Description"}),e.description&&e.description.length>0?n.jsx("pre",{className:"text-body whitespace-pre-wrap leading-relaxed text-fg font-sans",children:e.description}):n.jsx("p",{className:"text-body text-fg-muted italic",children:"No description."})]})]})}function Oe({node:e,onOpenBead:t}){const{deps:s,blocks:r}=e,i=s.length>0||r.length>0;return n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:"Dependencies"}),i?n.jsxs("div",{className:"space-y-6",children:[s.length>0&&n.jsxs("div",{children:[n.jsxs("p",{className:"text-label uppercase tracking-wider text-fg-muted mb-2",children:["Needs ",n.jsx("span",{className:"tnum",children:s.length})]}),n.jsx("ul",{className:"space-y-1",children:s.map(l=>n.jsx(C,{relation:l.kind==="needs"?null:l.kind,targetId:l.id,targetTitle:l.bead?.title??null,...l.bead&&t?{onOpenBead:t}:{}},`needs-${l.id}`))})]}),r.length>0&&n.jsxs("div",{children:[n.jsxs("p",{className:"text-label uppercase tracking-wider text-fg-muted mb-2",children:["Blocks ",n.jsx("span",{className:"tnum",children:r.length})]}),n.jsx("ul",{className:"space-y-1",children:r.map(l=>n.jsx(C,{relation:null,targetId:l.id,targetTitle:l.title,...t?{onOpenBead:t}:{}},`blocks-${l.id}`))})]})]}):n.jsx("p",{className:"text-body text-fg-muted italic",children:"No dependencies."})]})}function C({relation:e,targetId:t,targetTitle:s,onOpenBead:r}){const i=n.jsxs(n.Fragment,{children:[e&&n.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-faint",children:[e," "]}),n.jsx("span",{className:"tnum text-fg-muted",children:t}),s&&n.jsxs("span",{className:"text-fg",children:[" · ",s]})]});return n.jsx("li",{className:"text-body leading-snug",children:r?n.jsx("button",{type:"button",onClick:()=>r(t),className:"text-left text-fg-muted hover:text-fg focus-mark rounded-sm",title:`Open ${t}`,children:i}):n.jsxs("span",{title:"Outside the fetched window",children:[i," ",n.jsx("span",{className:"text-warn text-label uppercase tracking-wider",children:"unresolved"})]})})}function Pe({open:e,onClose:t,session:s,beadTitle:r}){const i=P(s);return n.jsx(U,{open:e,onClose:t,title:r,caption:s===null?"No live session resolved for this bead.":i?"Live transcript from the supervisor's session stream.":"Snapshot from the supervisor's transcript API.",widthClass:"max-w-5xl",children:n.jsx(ee,{sessionId:s?.id??null,stream:i,showBadge:!0,showCaption:!0})})}const Ue=6,De=3600*1e3,ze=3,Ge=["bead","formula_run","session","github_pr","github_issue","order_run"],He={bead:"Beads",session:"Sessions",github_pr:"Pull requests",github_issue:"Issues",formula_run:"Formula runs",order_run:"Order runs"};function Ke({view:e,loading:t,error:s,now:r,onOpenBead:i}){const[l,u]=h.useState(!1),a=h.useMemo(()=>Xe(e),[e]),o=h.useMemo(()=>Je(e),[e]),c=o.unresolved>=ze;return n.jsxs("section",{className:"mt-12",children:[n.jsxs("header",{className:"flex items-baseline justify-between mb-4 gap-3",children:[n.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Related"}),n.jsxs("div",{className:"flex items-baseline gap-3 min-w-0",children:[e&&e.asOf&&n.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum shrink-0",children:["as of ",D(e.asOf,r)]}),n.jsx(Ve,{loading:t,counts:o,showMark:c})]})]}),s!==null?n.jsx("p",{className:"text-body text-accent",role:"alert",children:s}):t&&e===null?n.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading related entities."}):e===null||a.length===0?n.jsx("p",{className:"text-body text-fg-muted italic",children:"No related entities."}):n.jsxs(n.Fragment,{children:[e.partial&&n.jsx("p",{className:"text-label uppercase tracking-wider text-warn mb-4",role:"status",children:"Partial: some sources did not load. Links may be incomplete."}),n.jsx("button",{type:"button",onClick:()=>u(f=>!f),className:"text-label uppercase tracking-wider text-fg-faint hover:text-fg focus-mark mb-4","aria-expanded":l,children:l?"Hide detail":"Show detail"}),l&&n.jsx("div",{className:"space-y-8",children:a.map(f=>n.jsx(qe,{type:f.type,rows:f.rows,now:r,...i!==void 0?{onOpenBead:i}:{}},f.type))})]})]})}function Ve({loading:e,counts:t,showMark:s}){if(e)return n.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint",children:"·"});const r=[];t.resolved>0&&r.push(`${t.resolved} resolved`),t.unresolved>0&&r.push(`${t.unresolved} unresolved`),t.candidates>0&&r.push(`${t.candidates} candidates`);const i=r.length>0?r.join(", "):"none";return n.jsxs("span",{className:`text-label uppercase tracking-wider tnum truncate ${s?"text-accent":"text-fg-faint"}`,children:[s&&n.jsx("span",{"aria-hidden":!0,children:"■ "}),i]})}function qe({type:e,rows:t,now:s,onOpenBead:r}){const i=t.slice(0,Ue),l=t.length-i.length;return n.jsxs("div",{children:[n.jsxs("header",{className:"flex items-baseline justify-between mb-2",children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint",children:He[e]}),n.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:t.length})]}),n.jsx("ul",{className:"space-y-2",children:i.map(u=>n.jsx(We,{row:u,now:s,...r!==void 0?{onOpenBead:r}:{}},`${u.relation}\0${u.node.key}`))}),l>0&&n.jsxs("p",{className:"text-label uppercase tracking-wider text-fg-faint mt-2",children:["+ ",l," more"]})]})}function We({row:e,now:t,onOpenBead:s}){const{node:r,relation:i}=e,l=Qe(r.fetchedAt,t),u=r.title??r.ref,a=r.unresolved||l;return n.jsxs("li",{className:"flex items-baseline gap-3 min-w-0",children:[n.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint shrink-0 w-20 truncate",children:i}),n.jsx("span",{className:"min-w-0 flex-1 truncate",children:n.jsx(Ye,{node:r,label:u,dimmed:a,...s!==void 0?{onOpenBead:s}:{}})}),n.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum shrink-0",children:r.unresolved?Ze(r):r.fetchedAt?D(r.fetchedAt,t):r.status??"·"})]})}function Ye({node:e,label:t,dimmed:s,onOpenBead:r}){const i=`text-body text-left truncate min-w-0 focus-mark ${s?"text-fg-muted":"text-fg hover:text-accent"}`;return e.type==="bead"&&!e.unresolved&&r?n.jsx("button",{type:"button",onClick:()=>r(e.ref),className:i,title:`Open ${e.ref}`,children:t}):e.type==="session"&&!e.unresolved?n.jsx(J,{to:`/agents/${encodeURIComponent(e.ref)}`,className:i,children:t}):e.url?n.jsxs("a",{href:e.url,target:"_blank",rel:"noreferrer noopener",className:i,title:e.url,children:[t," ",n.jsx("span",{"aria-hidden":!0,children:"↗"})]}):n.jsx("span",{className:i,children:t})}function Ze(e){return e.candidateCount!==void 0&&e.candidateCount>1?`${e.candidateCount} candidates`:"unresolved"}function Je(e){const t={resolved:0,unresolved:0,candidates:0};if(e===null)return t;for(const s of e.nodes)s.key!==e.focus.key&&(s.candidateCount!==void 0&&s.candidateCount>1?t.candidates+=1:s.unresolved?t.unresolved+=1:t.resolved+=1);return t}function Xe(e){if(e===null)return[];const t=new Map;for(const i of e.nodes)t.set(i.key,i);const s=new Map;for(const i of e.edges){if(i.from!==e.focus.key)continue;const l=t.get(i.to);if(l===void 0)continue;const u=s.get(l.type)??[];u.push({node:l,relation:i.relation}),s.set(l.type,u)}const r=[];for(const i of Ge){const l=s.get(i);l&&l.length>0&&(l.sort((u,a)=>Number(u.node.unresolved)-Number(a.node.unresolved)),r.push({type:i,rows:l}))}return r}function Qe(e,t){if(e===null)return!1;const s=Date.parse(e);return Number.isFinite(s)?t-s>De:!1}function lt({open:e,onClose:t,beadId:s,initialBead:r=null,onOpenBead:i,depNode:l=null,sessions:u,renderActions:a}){const{bead:o,loading:c,error:f,notFound:m,now:d}=we(e,s,r),g=Be(e?s:null),[z,w]=h.useState(!1),S=o&&u&&o.assignee&&o.assignee.length>0?te(o.assignee,u):null,I=P(S),R=o?a?.(o):void 0,G=R||I?n.jsxs(n.Fragment,{children:[R,I&&n.jsx(X,{size:"sm",tone:"quiet",onClick:()=>w(!0),children:"View live run"})]}):void 0;return n.jsxs(n.Fragment,{children:[n.jsx(U,{open:e,onClose:t,title:o?.title??s??"Bead",caption:o?n.jsxs("span",{children:[n.jsx("code",{className:"text-fg-muted",children:o.id})," · ",o.issue_type," · P",o.priority==null?"—":o.priority]}):s?n.jsx("code",{className:"text-fg-muted",children:s}):void 0,widthClass:"max-w-3xl",footer:G,children:m?n.jsxs("div",{className:"space-y-2",children:[n.jsx("p",{className:"text-fg-muted",children:"This decision was resolved or removed."}),n.jsx("p",{className:"text-fg-faint text-sm",children:"The bead it pointed to is no longer in the supervisor — it was likely closed or pruned since this link was surfaced."})]}):f?n.jsx("p",{className:"text-accent",role:"alert",children:f}):c&&o===null?n.jsx("p",{className:"text-fg-muted italic",children:"Fetching bead."}):o===null?n.jsx("p",{className:"text-fg-muted italic",children:"No bead."}):n.jsxs("div",{className:"space-y-8",children:[n.jsx(Ce,{bead:o}),l&&n.jsx(Oe,{node:l,...i!==void 0?{onOpenBead:i}:{}}),n.jsx(Ke,{view:g.view,loading:g.loading,error:g.error,now:d,...i!==void 0?{onOpenBead:i}:{}})]})}),o&&n.jsx(Pe,{open:z,onClose:()=>w(!1),session:S,beadTitle:o.title})]})}export{lt as B,Ke as R,Be as u}; +import{r as h,u as H,a0 as K,a1 as O,J as V,I as E,a2 as q,z as W,j as n,S as Y,a3 as Z,L as J,B as X}from"./index-C20tCZFz.js";import{f as Q}from"./format-fte2CeYD.js";import{F as x}from"./Field-Dsl4x4KL.js";import{a as P,L as ee}from"./LiveSessionPeek-jm19JJ4Z.js";import{M as U}from"./constants-DBKWGg29.js";import{f as D}from"./time-D9v0saHV.js";function te(e,t){if(e.length===0||t.length===0)return null;const s=t.filter(r=>r.state==="active");return F(e,s)??F(e,t)}function F(e,t){for(const s of t)if(se(s,e))return s;return null}function se(e,t){return e.alias===t||e.pool===t||e.alias!==void 0&&A(e.alias,["/","."])===t||e.session_name!==void 0&&A(e.session_name,["__","--"])===t}function A(e,t){let s=-1,r=0;for(const i of t){const l=e.lastIndexOf(i);l>s&&(s=l,r=i.length)}return s<0?e:e.slice(s+r)}const ne=/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;function re(e,t,s){return`${e}:${s}:${t}`}function b(e,t,s){e?.(t,s)}const ie=/^pr\/(\d{1,9})$/,le=/^issue\/(\d{1,9})$/;function oe(e){const t=e.trim();if(t.length===0)return{ok:!1,error:"empty ref"};const s=ie.exec(t);if(s?.[1])return{ok:!0,type:"github_pr",value:s[1]};const r=le.exec(t);return r?.[1]?{ok:!0,type:"github_issue",value:r[1]}:ne.test(t)?{ok:!0,type:"bead",value:t}:{ok:!1,error:"unrecognised ref"}}function M(e){if(typeof e!="string")return null;const t=e.trim();return/^https?:\/\//i.test(t)?t:null}function v(e,t,s){return re(e,t,s)}function ae(e){return{key:v("bead",e.id,e.scope),type:"bead",ref:e.id}}function _(e,t){const s=e.stats.get(t);if(s)return s;const r={relation:t,resolved:0,unresolved:0,nCandidates:0};return e.stats.set(t,r),r}function y(e,t){e.nodesByKey.has(t.key)||(e.nodesByKey.set(t.key,t),e.view.nodes.push(t))}function k(e,t,s,r,i,l){e.view.edges.push({from:t,to:s,relation:r,provenance:i,resolved:l})}function N(e,t,s,r,i){const l=ae(s);y(e,{...l,title:s.title,status:s.status,url:null,fetchedAt:i,unresolved:!1}),k(e,t,l.key,r,"supervisor",!0),_(e,r).resolved+=1,b(e.recorder,r,"resolved")}function ue(e,t){return{focus:e,nodes:[],edges:[],stats:[],partial:!1,generatedAt:t,asOf:null}}function $(e,t){return e===null?t:t===null||Date.parse(e)<=Date.parse(t)?e:t}function ce(e,t,s={}){const i=(s.now??(()=>new Date))().toISOString(),l=s.supervisorFetchedAt??null,u=s.githubFetchedAt??null,a=de(e,t),o=ue(a.focus,i);o.partial=s.partial??!1;const c={view:o,nodesByKey:new Map,stats:new Map,recorder:s.recorder??(()=>{})};if(y(c,a.focusNode),!a.focusResolved)return o.partial=!0,L(c,l,u),o;const f=a.focusNode.key;if(a.beadFocus)for(const m of a.beads)pe(c,m,f,e,l,u);else for(const m of a.beads)N(c,f,m,"bead",l);return L(c,l,u),o}function de(e,t){if(t.type==="github_pr"||t.type==="github_issue"){const u=t.type==="github_pr"?"github_pr":"github_issue",a=t.type==="github_pr"?`pr/${t.value}`:`issue/${t.value}`,c=(t.type==="github_pr"?e.beadsForPr.get(t.value)??[]:e.beadsForIssue.get(t.value)??[]).map(m=>e.beads.get(m)).filter(m=>m!==void 0),f={key:v(u,t.value,"github"),type:u,ref:a};return{focus:f,focusNode:{...f,title:null,status:null,url:null,fetchedAt:null,unresolved:c.length===0,...c.length>1?{candidateCount:c.length}:{}},beads:c,focusResolved:c.length>0,beadFocus:!1}}const s=e.beads.get(t.value)??e.allBeads.get(t.value);if(s!==void 0){const u=fe(s);return{focus:u,focusNode:{...u,title:s.title,status:s.status,url:null,fetchedAt:null,unresolved:!1},beads:s.superseded?[]:[s],focusResolved:!0,beadFocus:!0}}const r=e.beadsForSession.get(t.value)??[],i=e.sessions.has(t.value);if(r.length>0||i){const u={key:v("session",t.value,"session"),type:"session",ref:t.value},a=r.map(c=>e.beads.get(c)).filter(c=>c!==void 0),o=i||a.length>0;return{focus:u,focusNode:{...u,title:e.sessions.get(t.value)?.title??null,status:e.sessions.get(t.value)?.state??null,url:null,fetchedAt:null,unresolved:!o},beads:a,focusResolved:o,beadFocus:!1}}const l={key:v("bead",t.value,"unknown"),type:"bead",ref:t.value};return{focus:l,focusNode:{...l,title:null,status:null,url:null,fetchedAt:null,unresolved:!0},beads:[],focusResolved:!1,beadFocus:!0}}function fe(e){return{key:v("bead",e.id,e.scope),type:"bead",ref:e.id}}function pe(e,t,s,r,i,l){if(t.parentBeadId){const a=r.beads.get(t.parentBeadId);a?N(e,s,a,"parent",i):me(e,s,t.parentBeadId,"parent")}const u=(r.childrenOf.get(t.id)??[]).filter(a=>a!==t.id);for(const a of u){const o=r.beads.get(a);o&&N(e,s,o,"child",i)}if(t.moleculeId){const a=(r.membersOfMolecule.get(t.moleculeId)??[]).filter(o=>o!==t.id&&o!==t.moleculeId);if(t.moleculeId!==t.id){const o=r.beads.get(t.moleculeId);o&&N(e,s,o,"molecule",i)}for(const o of a){const c=r.beads.get(o);c&&N(e,s,c,"molecule",i)}}if(t.prNumber&&B(e,s,"github_pr",`pr/${t.prNumber}`,t.prNumber,M(t.prUrl),"pr","supervisor",l),t.issueNumber&&B(e,s,"github_issue",`issue/${t.issueNumber}`,t.issueNumber,M(t.issueUrl),"issue","supervisor",l),t.sessionId){const a=r.sessions.get(t.sessionId),o={key:v("session",t.sessionId,"session"),type:"session",ref:t.sessionId};a?(y(e,{...o,title:a.title??a.alias??t.sessionName??null,status:a.state??null,url:null,fetchedAt:i,unresolved:!1}),k(e,s,o.key,"session","supervisor",!0),_(e,"session").resolved+=1,b(e.recorder,"session","resolved")):(y(e,{...o,title:t.sessionName??null,status:null,url:null,fetchedAt:i,unresolved:!0}),k(e,s,o.key,"session","supervisor",!1),_(e,"session").unresolved+=1,b(e.recorder,"session","unresolved"))}}function B(e,t,s,r,i,l,u,a,o){const c=v(s,i,"github");y(e,{key:c,type:s,ref:r,title:null,status:null,url:l,fetchedAt:o,unresolved:!0}),k(e,t,c,u,a,!1),_(e,u).unresolved+=1,b(e.recorder,u,"unresolved")}function me(e,t,s,r){const i=v("bead",s,"unknown");y(e,{key:i,type:"bead",ref:s,title:null,status:null,url:null,fetchedAt:null,unresolved:!0}),k(e,t,i,r,"supervisor",!1),_(e,r).unresolved+=1,b(e.recorder,r,"unresolved")}function L(e,t,s){e.view.stats=[...e.stats.values()].sort((i,l)=>i.relation.localeCompare(l.relation));let r=null;for(const i of e.view.nodes)r=$(r,i.fetchedAt);e.view.asOf=r??$(t,s)}function p(e,t){const s=e.metadata?.[t];if(typeof s=="string"){const r=s.trim();return r.length>0?r:void 0}if(typeof s=="number"&&Number.isFinite(s))return String(s)}function he(e,t){const s=e.metadata?.[t];if(typeof s=="number"&&Number.isInteger(s)&&s>=0)return s;if(typeof s=="string"&&/^\d+$/.test(s.trim()))return Number.parseInt(s.trim(),10)}const ge=["gc.scope_ref","scope_ref","scope_id"],xe=["gc.scope_kind","scope_kind"];function ve(e,t){let s;for(const i of ge){const l=p(e,i);if(l!==void 0){s=l;break}}let r;for(const i of xe){const l=p(e,i);if(l!==void 0){r=l;break}}return s===void 0?`city:${t}`:`${r??"rig"}:${s}`}const ye=/^github-pr:[^/]+\/[^/]+\/(\d+)$/,je=/\/(?:pull\/)?(\d+)(?:[/?#]|$)/;function Ne(e){const t=p(e,"evidence.pr_url"),s=p(e,"evidence.pr_number"),r=p(e,"evidence.artifact_path"),i=p(e,"pr_review.pr_number"),l=p(e,"pr_review.pr_url"),u=r?.match(ye),a=t?.match(je),o=s??u?.[1]??a?.[1]??i??void 0,c=t??l??void 0,f={};return o!==void 0&&(f.prNumber=o),c!==void 0&&(f.prUrl=c),f}function be(e,t){const{prNumber:s,prUrl:r}=Ne(e),i={id:e.id,title:e.title,status:e.status,scope:ve(e,t),superseded:!1},l={parentBeadId:p(e,"gc.parent_bead_id"),rootBeadId:p(e,"gc.root_bead_id"),moleculeId:p(e,"molecule_id"),prNumber:s,prUrl:r,issueNumber:p(e,"bugflow.github_issue_number")??p(e,"design_review.github_issue_number"),issueUrl:p(e,"bugflow.github_issue_url")??p(e,"design_review.github_issue_url"),sessionId:p(e,"session_id"),sessionName:p(e,"session_name"),stepId:p(e,"gc.step_id"),attempt:he(e,"gc.attempt")};for(const[u,a]of Object.entries(l))a!==void 0&&Object.assign(i,{[u]:a});return i}function T(e){return`${e.moleculeId}\0${e.stepId}`}function _e(e){const t=new Map;for(const s of e){if(s.moleculeId===void 0||s.stepId===void 0||s.attempt===void 0)continue;const r=T(s),i=t.get(r);(i===void 0||s.attempt>i)&&t.set(r,s.attempt)}for(const s of e){if(s.moleculeId===void 0||s.stepId===void 0||s.attempt===void 0)continue;const r=t.get(T(s));r!==void 0&&s.attemptbe(d,s));_e(r);const i=new Map,l=new Map,u=new Map,a=new Map,o=new Map,c=new Map,f=new Map;for(const d of r)i.set(d.id,d),!d.superseded&&(l.set(d.id,d),d.parentBeadId&&j(u,d.parentBeadId,d.id),d.moleculeId&&j(a,d.moleculeId,d.id),d.prNumber&&j(o,d.prNumber,d.id),d.issueNumber&&j(c,d.issueNumber,d.id),d.sessionId&&j(f,d.sessionId,d.id));const m=new Map;for(const d of t)m.set(d.id,d);return{beads:l,allBeads:i,childrenOf:u,membersOfMolecule:a,beadsForPr:o,beadsForIssue:c,beadsForSession:f,sessions:m}}function we(e,t,s=null){const[r,i]=h.useState(s),[l,u]=h.useState(!1),[a,o]=h.useState(null),[c,f]=h.useState(!1),m=H();return h.useEffect(()=>{if(!e||!t)return;if(s&&s.id===t&&s.description!==void 0){i(s),o(null),f(!1);return}i(s?.id===t?s:null),u(!0),o(null),f(!1);let d=!1;return(async()=>{try{const g=await K(t);d||i(g)}catch(g){if(d)return;g instanceof O&&g.status===404?f(!0):o(Se(g))}finally{d||u(!1)}})(),()=>{d=!0}},[e,t,s]),{bead:r,loading:l,error:a,notFound:c,now:m}}function Se(e){return e instanceof O?e.status===void 0?e.message:`${e.status} ${e.message}`:e instanceof Error?e.message:"fetch failed"}function Ie(e){return e.partial===!0||(e.partial_errors?.length??0)>0||(e.next_cursor?.length??0)>0}function Re(e,t){return Ie(e)||typeof e.total=="number"&&e.total>t}const Ee=1e3;async function Fe(e){const t=oe(e);if(!t.ok)throw new Error(t.error);const s=V("load supervisor entity links"),r=new Date().toISOString(),i=await E().listBeads(s,{limit:Ee}),l=Ae(i.items??[]);let u=Re(i,l.length),a=[];try{const c=await E().listSessions(s);a=q(c),u||=$e(c)}catch{u=!0}const o=ke(l,a,s);return ce(o,t,{partial:u,supervisorFetchedAt:r,githubFetchedAt:null})}function Ae(e){return e.map(Me)}function Me(e){const t={id:e.id,title:e.title,status:e.status,issue_type:e.issue_type,priority:e.priority??null,created_at:e.created_at};return e.description!==void 0&&(t.description=e.description),e.assignee!==void 0&&(t.assignee=e.assignee),Array.isArray(e.labels)&&(t.labels=e.labels),e.metadata!==void 0&&(t.metadata=e.metadata),e.ref!==void 0&&(t.ref=e.ref),e.parent!==void 0&&(t.parent=e.parent),e.from!==void 0&&(t.from=e.from),e.ephemeral!==void 0&&(t.ephemeral=e.ephemeral),e.needs!==void 0&&(t.needs=e.needs),e.dependencies!==void 0&&(t.dependencies=e.dependencies),e.updated_at!==void 0&&(t.updated_at=e.updated_at),t}function $e(e){return e.partial===!0||(e.partial_errors?.length??0)>0}function Be(e){const[t,s]=h.useState(null),[r,i]=h.useState(!1),[l,u]=h.useState(null);return h.useEffect(()=>{if(e===null||e.length===0){s(null),u(null),i(!1);return}let a=!1;return i(!0),u(null),(async()=>{try{const o=await Fe(e);a||s(o)}catch(o){if(a)return;u(W(o,"related entities failed")),s(null)}finally{a||i(!1)}})(),()=>{a=!0}},[e]),{view:t,loading:r,error:l}}function Le(e){const t=e.metadata;if(!t)return{};const s={};return t["gc.kind"]&&(s.kind=t["gc.kind"]),t["gc.source_bead_id"]&&(s.originBeadId=t["gc.source_bead_id"]),t["gc.formula_contract"]&&(s.formulaContract=t["gc.formula_contract"]),t["gc.run_target"]?s.runTarget=t["gc.run_target"]:t["gc.routed_to"]&&(s.runTarget=t["gc.routed_to"]),s}function Te(e,t){return t.kind==="run"?"template":e.issue_type==="molecule"?"wisp":"work"}function Ce({bead:e}){const t=Le(e),s=Te(e,t);return n.jsxs("div",{className:"space-y-8",children:[s==="template"&&n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:"Formula template"}),n.jsxs("p",{className:"text-body text-fg-muted max-w-prose",children:["This bead is a recipe, not actionable work. Every"," ",e.ref?n.jsx("code",{className:"text-fg-muted",children:e.ref}):"wisp"," instance is instantiated from this template. The ",n.jsx("span",{className:"text-fg-muted",children:"in_progress"})," ","status is the gc-system convention for ",'"',"available for instantiation",'"'," — do not act on it, nudge it, or close it."]})]}),s==="wisp"&&n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:"Formula instance"}),n.jsxs("p",{className:"text-body text-fg-muted max-w-prose",children:["One run of the"," ",e.title?n.jsx("code",{className:"text-fg-muted",children:e.title}):"formula"," recipe."]})]}),n.jsxs("dl",{className:"grid grid-cols-2 sm:grid-cols-4 gap-x-8 gap-y-5",children:[n.jsx(x,{label:"Status",children:n.jsx(Y,{tone:Z(e.status),label:e.status})}),n.jsx(x,{label:"Type",children:e.issue_type}),n.jsx(x,{label:"Assignee",children:e.assignee||"·"}),n.jsx(x,{label:"Created",children:n.jsx("span",{className:"tnum",children:Q(e.created_at)})})]}),s==="template"&&(t.formulaContract||t.originBeadId||t.runTarget)&&n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:"Template origin"}),n.jsx("p",{className:"text-body text-fg-muted max-w-prose mb-4",children:"Where this formula came from, kept for traceability. The origin bead and target may be stale; the formula itself is now used wherever the pool dispatches it."}),n.jsxs("dl",{className:"grid grid-cols-2 sm:grid-cols-3 gap-x-8 gap-y-3",children:[t.formulaContract&&n.jsx(x,{label:"Contract",children:n.jsx("code",{className:"text-fg-muted",children:t.formulaContract})}),e.ref&&n.jsx(x,{label:"Ref",children:n.jsx("code",{className:"text-fg-muted",children:e.ref})}),t.originBeadId&&n.jsx(x,{label:"Origin bead",children:n.jsx("code",{className:"text-fg-muted",children:t.originBeadId})}),t.runTarget&&n.jsx(x,{label:"Origin target",children:n.jsx("span",{className:"text-fg-muted truncate",title:t.runTarget,children:t.runTarget})})]})]}),Array.isArray(e.labels)&&e.labels.length>0&&n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:"Labels"}),n.jsx("div",{className:"flex flex-wrap gap-x-3 gap-y-1",children:e.labels.map(r=>n.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:r},r))})]}),n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:s==="template"?"Recipe":"Description"}),e.description&&e.description.length>0?n.jsx("pre",{className:"text-body whitespace-pre-wrap leading-relaxed text-fg font-sans",children:e.description}):n.jsx("p",{className:"text-body text-fg-muted italic",children:"No description."})]})]})}function Oe({node:e,onOpenBead:t}){const{deps:s,blocks:r}=e,i=s.length>0||r.length>0;return n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:"Dependencies"}),i?n.jsxs("div",{className:"space-y-6",children:[s.length>0&&n.jsxs("div",{children:[n.jsxs("p",{className:"text-label uppercase tracking-wider text-fg-muted mb-2",children:["Needs ",n.jsx("span",{className:"tnum",children:s.length})]}),n.jsx("ul",{className:"space-y-1",children:s.map(l=>n.jsx(C,{relation:l.kind==="needs"?null:l.kind,targetId:l.id,targetTitle:l.bead?.title??null,...l.bead&&t?{onOpenBead:t}:{}},`needs-${l.id}`))})]}),r.length>0&&n.jsxs("div",{children:[n.jsxs("p",{className:"text-label uppercase tracking-wider text-fg-muted mb-2",children:["Blocks ",n.jsx("span",{className:"tnum",children:r.length})]}),n.jsx("ul",{className:"space-y-1",children:r.map(l=>n.jsx(C,{relation:null,targetId:l.id,targetTitle:l.title,...t?{onOpenBead:t}:{}},`blocks-${l.id}`))})]})]}):n.jsx("p",{className:"text-body text-fg-muted italic",children:"No dependencies."})]})}function C({relation:e,targetId:t,targetTitle:s,onOpenBead:r}){const i=n.jsxs(n.Fragment,{children:[e&&n.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-faint",children:[e," "]}),n.jsx("span",{className:"tnum text-fg-muted",children:t}),s&&n.jsxs("span",{className:"text-fg",children:[" · ",s]})]});return n.jsx("li",{className:"text-body leading-snug",children:r?n.jsx("button",{type:"button",onClick:()=>r(t),className:"text-left text-fg-muted hover:text-fg focus-mark rounded-sm",title:`Open ${t}`,children:i}):n.jsxs("span",{title:"Outside the fetched window",children:[i," ",n.jsx("span",{className:"text-warn text-label uppercase tracking-wider",children:"unresolved"})]})})}function Pe({open:e,onClose:t,session:s,beadTitle:r}){const i=P(s);return n.jsx(U,{open:e,onClose:t,title:r,caption:s===null?"No live session resolved for this bead.":i?"Live transcript from the supervisor's session stream.":"Snapshot from the supervisor's transcript API.",widthClass:"max-w-5xl",children:n.jsx(ee,{sessionId:s?.id??null,stream:i,showBadge:!0,showCaption:!0})})}const Ue=6,De=3600*1e3,ze=3,Ge=["bead","formula_run","session","github_pr","github_issue","order_run"],He={bead:"Beads",session:"Sessions",github_pr:"Pull requests",github_issue:"Issues",formula_run:"Formula runs",order_run:"Order runs"};function Ke({view:e,loading:t,error:s,now:r,onOpenBead:i}){const[l,u]=h.useState(!1),a=h.useMemo(()=>Xe(e),[e]),o=h.useMemo(()=>Je(e),[e]),c=o.unresolved>=ze;return n.jsxs("section",{className:"mt-12",children:[n.jsxs("header",{className:"flex items-baseline justify-between mb-4 gap-3",children:[n.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Related"}),n.jsxs("div",{className:"flex items-baseline gap-3 min-w-0",children:[e&&e.asOf&&n.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum shrink-0",children:["as of ",D(e.asOf,r)]}),n.jsx(Ve,{loading:t,counts:o,showMark:c})]})]}),s!==null?n.jsx("p",{className:"text-body text-accent",role:"alert",children:s}):t&&e===null?n.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading related entities."}):e===null||a.length===0?n.jsx("p",{className:"text-body text-fg-muted italic",children:"No related entities."}):n.jsxs(n.Fragment,{children:[e.partial&&n.jsx("p",{className:"text-label uppercase tracking-wider text-warn mb-4",role:"status",children:"Partial: some sources did not load. Links may be incomplete."}),n.jsx("button",{type:"button",onClick:()=>u(f=>!f),className:"text-label uppercase tracking-wider text-fg-faint hover:text-fg focus-mark mb-4","aria-expanded":l,children:l?"Hide detail":"Show detail"}),l&&n.jsx("div",{className:"space-y-8",children:a.map(f=>n.jsx(qe,{type:f.type,rows:f.rows,now:r,...i!==void 0?{onOpenBead:i}:{}},f.type))})]})]})}function Ve({loading:e,counts:t,showMark:s}){if(e)return n.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint",children:"·"});const r=[];t.resolved>0&&r.push(`${t.resolved} resolved`),t.unresolved>0&&r.push(`${t.unresolved} unresolved`),t.candidates>0&&r.push(`${t.candidates} candidates`);const i=r.length>0?r.join(", "):"none";return n.jsxs("span",{className:`text-label uppercase tracking-wider tnum truncate ${s?"text-accent":"text-fg-faint"}`,children:[s&&n.jsx("span",{"aria-hidden":!0,children:"■ "}),i]})}function qe({type:e,rows:t,now:s,onOpenBead:r}){const i=t.slice(0,Ue),l=t.length-i.length;return n.jsxs("div",{children:[n.jsxs("header",{className:"flex items-baseline justify-between mb-2",children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint",children:He[e]}),n.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:t.length})]}),n.jsx("ul",{className:"space-y-2",children:i.map(u=>n.jsx(We,{row:u,now:s,...r!==void 0?{onOpenBead:r}:{}},`${u.relation}\0${u.node.key}`))}),l>0&&n.jsxs("p",{className:"text-label uppercase tracking-wider text-fg-faint mt-2",children:["+ ",l," more"]})]})}function We({row:e,now:t,onOpenBead:s}){const{node:r,relation:i}=e,l=Qe(r.fetchedAt,t),u=r.title??r.ref,a=r.unresolved||l;return n.jsxs("li",{className:"flex items-baseline gap-3 min-w-0",children:[n.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint shrink-0 w-20 truncate",children:i}),n.jsx("span",{className:"min-w-0 flex-1 truncate",children:n.jsx(Ye,{node:r,label:u,dimmed:a,...s!==void 0?{onOpenBead:s}:{}})}),n.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum shrink-0",children:r.unresolved?Ze(r):r.fetchedAt?D(r.fetchedAt,t):r.status??"·"})]})}function Ye({node:e,label:t,dimmed:s,onOpenBead:r}){const i=`text-body text-left truncate min-w-0 focus-mark ${s?"text-fg-muted":"text-fg hover:text-accent"}`;return e.type==="bead"&&!e.unresolved&&r?n.jsx("button",{type:"button",onClick:()=>r(e.ref),className:i,title:`Open ${e.ref}`,children:t}):e.type==="session"&&!e.unresolved?n.jsx(J,{to:`/agents/${encodeURIComponent(e.ref)}`,className:i,children:t}):e.url?n.jsxs("a",{href:e.url,target:"_blank",rel:"noreferrer noopener",className:i,title:e.url,children:[t," ",n.jsx("span",{"aria-hidden":!0,children:"↗"})]}):n.jsx("span",{className:i,children:t})}function Ze(e){return e.candidateCount!==void 0&&e.candidateCount>1?`${e.candidateCount} candidates`:"unresolved"}function Je(e){const t={resolved:0,unresolved:0,candidates:0};if(e===null)return t;for(const s of e.nodes)s.key!==e.focus.key&&(s.candidateCount!==void 0&&s.candidateCount>1?t.candidates+=1:s.unresolved?t.unresolved+=1:t.resolved+=1);return t}function Xe(e){if(e===null)return[];const t=new Map;for(const i of e.nodes)t.set(i.key,i);const s=new Map;for(const i of e.edges){if(i.from!==e.focus.key)continue;const l=t.get(i.to);if(l===void 0)continue;const u=s.get(l.type)??[];u.push({node:l,relation:i.relation}),s.set(l.type,u)}const r=[];for(const i of Ge){const l=s.get(i);l&&l.length>0&&(l.sort((u,a)=>Number(u.node.unresolved)-Number(a.node.unresolved)),r.push({type:i,rows:l}))}return r}function Qe(e,t){if(e===null)return!1;const s=Date.parse(e);return Number.isFinite(s)?t-s>De:!1}function lt({open:e,onClose:t,beadId:s,initialBead:r=null,onOpenBead:i,depNode:l=null,sessions:u,renderActions:a}){const{bead:o,loading:c,error:f,notFound:m,now:d}=we(e,s,r),g=Be(e?s:null),[z,w]=h.useState(!1),S=o&&u&&o.assignee&&o.assignee.length>0?te(o.assignee,u):null,I=P(S),R=o?a?.(o):void 0,G=R||I?n.jsxs(n.Fragment,{children:[R,I&&n.jsx(X,{size:"sm",tone:"quiet",onClick:()=>w(!0),children:"View live run"})]}):void 0;return n.jsxs(n.Fragment,{children:[n.jsx(U,{open:e,onClose:t,title:o?.title??s??"Bead",caption:o?n.jsxs("span",{children:[n.jsx("code",{className:"text-fg-muted",children:o.id})," · ",o.issue_type," · P",o.priority==null?"—":o.priority]}):s?n.jsx("code",{className:"text-fg-muted",children:s}):void 0,widthClass:"max-w-3xl",footer:G,children:m?n.jsxs("div",{className:"space-y-2",children:[n.jsx("p",{className:"text-fg-muted",children:"This decision was resolved or removed."}),n.jsx("p",{className:"text-fg-faint text-sm",children:"The bead it pointed to is no longer in the supervisor — it was likely closed or pruned since this link was surfaced."})]}):f?n.jsx("p",{className:"text-accent",role:"alert",children:f}):c&&o===null?n.jsx("p",{className:"text-fg-muted italic",children:"Fetching bead."}):o===null?n.jsx("p",{className:"text-fg-muted italic",children:"No bead."}):n.jsxs("div",{className:"space-y-8",children:[n.jsx(Ce,{bead:o}),l&&n.jsx(Oe,{node:l,...i!==void 0?{onOpenBead:i}:{}}),n.jsx(Ke,{view:g.view,loading:g.loading,error:g.error,now:d,...i!==void 0?{onOpenBead:i}:{}})]})}),o&&n.jsx(Pe,{open:z,onClose:()=>w(!1),session:S,beadTitle:o.title})]})}export{lt as B,Ke as R,Be as u}; diff --git a/internal/api/dashboardspa/dist/assets/Beads-CRhPo2Gt.js b/internal/api/dashboardspa/dist/assets/Beads-DJjixOgD.js similarity index 97% rename from internal/api/dashboardspa/dist/assets/Beads-CRhPo2Gt.js rename to internal/api/dashboardspa/dist/assets/Beads-DJjixOgD.js index 9792f169db..6ddf91c7c3 100644 --- a/internal/api/dashboardspa/dist/assets/Beads-CRhPo2Gt.js +++ b/internal/api/dashboardspa/dist/assets/Beads-DJjixOgD.js @@ -1 +1 @@ -import{j as e,S as je,B as w,r as o,I as L,J as X,a as Le,g as Fe,K as Te,b as Y,c as De,l as qe,f as ze,z as fe,R as xe,i as J,H as He,G as Ke}from"./index-BFDP6Xwd.js";import{b as Ve,r as Ge}from"./routeHighlight-B30gQO2o.js";import{B as Ue}from"./BeadDetailModal-BKOlUSQL.js";import{u as Ye,F as Je}from"./useListFilters-CE9qAvrH.js";import{L as Xe,f as Qe}from"./projectOf-CJPpTC86.js";import{M as be}from"./constants-DOaI3lZl.js";import{P as We}from"./PageHeader-5RHLpIfH.js";import{l as Ze}from"./agentReads-DcDPDlRM.js";import"./format-fte2CeYD.js";import"./Field-BpdGqWpv.js";import"./LiveSessionPeek-Cjv3DcC3.js";import"./time-D9v0saHV.js";function et(n){if(n===void 0)return null;const s=n.indexOf("?");if(s<0)return null;const l=new URLSearchParams(n.slice(s+1)).get("bead");return l!==null&&l.length>0?l:null}function tt({items:n,onOpen:s}){const l=n.filter(a=>a.severity==="attention"||a.severity==="watch");return l.length===0?null:e.jsxs("section",{"aria-labelledby":"beads-attention-title",className:"mb-8 space-y-3",children:[e.jsxs("h2",{id:"beads-attention-title",className:"text-label uppercase tracking-wider text-fg-muted",children:["Needs you ",e.jsxs("span",{className:"tnum text-fg",children:["(",l.length,")"]})]}),e.jsx("ul",{className:"space-y-2",children:l.map(a=>{const i=et(a.href);return e.jsxs("li",{className:"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1",children:[e.jsxs("div",{className:"min-w-0 space-y-0.5",children:[e.jsx(je,{tone:a.severity==="attention"?"stuck":"warn",label:a.title}),a.summary!==void 0&&e.jsx("p",{className:"text-body text-fg-muted",children:a.summary})]}),i!==null&&e.jsx("div",{className:"flex items-center gap-2",children:e.jsx(w,{type:"button",size:"sm",tone:"quiet",onClick:()=>s(i),children:"Open"})})]},a.id)})})]})}const ae=[{id:"ready",label:"ready"},{id:"open",label:"open"},{id:"in_progress",label:"in progress"},{id:"blocked",label:"blocked"},{id:"done",label:"done"}];function st(n){const s=new Set,l=[];for(const a of n.needs??[])a.length===0||s.has(a)||(s.add(a),l.push({id:a,kind:"needs"}));for(const a of n.dependencies??[]){const i=a.depends_on_id;i.length===0||s.has(i)||(s.add(i),l.push({id:i,kind:a.type}))}return l}function nt(n){return(n.needs??[]).filter(s=>s.length>0)}function at(n){switch(n.bead.status){case"in_progress":return"in_progress";case"blocked":return"blocked";case"closed":return"done";default:return n.ready?"ready":"open"}}function lt(n,s){const l=n.bead.priority??Number.POSITIVE_INFINITY,a=s.bead.priority??Number.POSITIVE_INFINITY;return l!==a?l-a:n.bead.ids.bead.id?1:0}function rt(n){const s=new Map;for(const r of n)s.set(r.id,r);const l=new Map,a=new Map;for(const r of n){const c=st(r).map(({id:m,kind:g})=>({id:m,kind:g,bead:s.get(m)??null})),u=c.some(m=>m.bead===null),d=nt(r),h=r.status==="open"&&d.every(m=>s.get(m)?.status==="closed"),p={bead:r,deps:c,blocks:[],ready:h,hasUnresolvedDeps:u,column:"open"};p.column=at(p),a.set(r.id,p);for(const m of c){if(m.bead===null)continue;const g=l.get(m.id);g?g.push(r):l.set(m.id,[r])}}for(const[r,c]of l){const u=a.get(r);u&&(u.blocks=[...c].sort((d,h)=>d.idh.id?1:0))}const i=Ne();for(const r of a.values())i[r.column].push(r);for(const r of ae)i[r.id].sort(lt);return{nodes:a,columns:i}}function Ne(){return{ready:[],open:[],in_progress:[],blocked:[],done:[]}}function ot(n,s){const l=Ne();for(const a of ae)l[a.id]=n.columns[a.id].filter(i=>s.has(i.bead.id));return l}function it({node:n,selected:s,attentionSeverity:l=null,onSelect:a}){const{bead:i,deps:r,blocks:c,hasUnresolvedDeps:u}=n,d=o.useRef(null),h=r.length,p=c.length,m=h>0||p>0,{className:g="",...R}=Ve(l);return o.useEffect(()=>{s&&d.current?.scrollIntoView?.({block:"center",inline:"nearest"})},[s]),e.jsx("li",{ref:d,...R,className:`px-2 py-2 -mx-2 rounded-sm transition-colors duration-150 ease-out-quart ${s?"bg-surface-tint":"hover:bg-surface-tint/60"} ${g}`,children:e.jsxs("button",{type:"button",onClick:()=>a(i.id),className:"text-left w-full focus-mark rounded-sm","aria-pressed":s,title:`Select ${i.id}`,children:[e.jsxs("span",{className:"flex items-baseline gap-2",children:[e.jsx("span",{className:"text-fg-faint","aria-hidden":"true",children:s?"▸":" "}),e.jsx("span",{className:`min-w-0 line-clamp-2 text-body ${s?"text-fg font-medium":"text-fg"}`,children:i.title})]}),e.jsxs("span",{className:"flex items-baseline gap-3 pl-4 mt-0.5 text-label uppercase tracking-wider text-fg-faint",children:[e.jsx("span",{className:"tnum",children:i.id}),i.priority!=null&&e.jsxs("span",{className:"tnum",children:["P",i.priority]}),m&&e.jsxs("span",{className:"tnum normal-case tracking-normal",children:[h>0&&`needs ${h}`,h>0&&p>0&&" · ",p>0&&`blocks ${p}`]}),u&&e.jsx("span",{className:"normal-case tracking-normal text-warn",children:"unresolved"})]})]})})}function ct({columns:n,selectedId:s,attentionSeverity:l,onSelect:a}){return e.jsx("div",{className:"grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-x-8 gap-y-8",children:ae.map(i=>{const r=n[i.id],u=i.id==="blocked"&&r.length>0?"text-accent":"text-fg-muted";return e.jsxs("section",{"aria-label":i.label,children:[e.jsxs("header",{className:"flex items-baseline justify-between border-b border-rule pb-2 mb-3",children:[e.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-muted",children:i.label}),e.jsx("span",{className:`text-label tnum ${u}`,children:r.length})]}),r.length===0?e.jsx("p",{className:"text-body text-fg-faint italic",children:"·"}):e.jsx("ul",{className:"space-y-1",children:r.map(d=>e.jsx(it,{node:d,selected:d.bead.id===s,attentionSeverity:l?.(d.bead.id)??null,onSelect:a},d.bead.id))})]},i.id)})})}function dt({label:n,count:s,graph:l,ids:a,selectedId:i,attentionSeverity:r,onSelect:c}){const u=ot(l,a);return e.jsxs("section",{"aria-label":n,children:[e.jsxs("header",{className:"flex items-baseline justify-between border-b border-rule pb-2 mb-4",children:[e.jsx("h2",{className:"text-headline text-fg",children:n}),e.jsx("span",{className:"text-label tnum text-fg-muted",children:s})]}),e.jsx(ct,{columns:u,selectedId:i,...r===void 0?{}:{attentionSeverity:r},onSelect:c})]})}function ut(n,s){const l=n?.trim();if(!l)return;const a=s.find(r=>r.name===l);return a?a.name:s.find(r=>r.path===l)?.name}function mt(n){return Array.from(new Set(n.map(s=>s.name.trim()).filter(s=>s.length>0))).sort((s,l)=>s.localeCompare(l))}async function pt(){const n=await L().listRigs(X("list supervisor rigs"));return{...n,items:n.items??[]}}async function gt(n,s){const l=s?.trim()??"";await L().closeBead(X("close supervisor bead"),n,l.length===0?void 0:{reason:l})}async function ht(n){const s=n.trim();if(s.length===0)throw new Error("agent alias is required");await L().nudgeAgent(X("nudge supervisor agent"),s)}async function ft(n){const s=n.title.trim(),l=n.description.trim(),a=n.rig.trim(),i=n.target.trim();if(s.length===0)throw new Error("bead title is required");if(i.length===0)throw new Error("sling target is required");const r=X("create and sling supervisor bead"),c={title:s};l.length>0&&(c.description=l);const u=await L().createBead(r,c),d={bead:u.id,target:i};a.length>0&&(d.rig=a);const h=await L().sling(r,d);return{bead:u,sling:h}}const xt=new Set,v="",we="closed",bt=1e4,ye=[{id:"open",label:"open",match:n=>n.status==="open"},{id:"in_progress",label:"in progress",match:n=>n.status==="in_progress"},{id:"blocked",label:"blocked",match:n=>n.status==="blocked"},{id:we,label:"closed",match:n=>n.status==="closed"}],yt=n=>[n.id,n.title,n.assignee,...n.labels??[]];function Mt(){const n=Le(),s=Fe(),a=He()??"no-city",[i]=Te(),r=jt(i.get("bead")),[c,u]=o.useState(v),[d,h]=o.useState(!1),[p,m]=o.useState(r),[g,R]=o.useState(null),[le,_]=o.useState(""),[k,re]=o.useState(null),[F,S]=o.useState(null),[Q,T]=o.useState(!1),[D,oe]=o.useState(!1),[ie,W]=o.useState(null),[q,ce]=o.useState(""),[Z,de]=o.useState(""),[A,ue]=o.useState(""),[y,$]=o.useState(""),{data:I,loading:z,error:me,refresh:E}=Y(`beads:board:${a}:${c}:${d?"all":"open"}`,()=>De({includeClosed:d,...c===v?{}:{rigFilter:c}})),ve=o.useMemo(()=>I?.items??[],[I]),pe=I?.total??0,ee=I?.upstream_total,te=I?.upstream_fetched,Ce=I?.fetch_limit,H=I!==void 0,K=Y(`sessions:${a}`,qe),ke=o.useMemo(()=>K.data?.items??[],[K.data]),M=Y(`agents:${a}`,Ze),N=o.useMemo(()=>M.data?.items??[],[M.data]),V=Y(`rigs:${a}`,pt),G=o.useMemo(()=>V.data?.items??[],[V.data]),C=o.useMemo(()=>mt(G),[G]),B=o.useCallback(t=>ut(t.rig,G),[G]),O=o.useMemo(()=>A.length===0?N:N.filter(t=>B(t)===A),[N,B,A]);o.useEffect(()=>{if(Q){if(O.length===0){y.length>0&&$("");return}O.some(t=>t.name===y)||$(O[0]?.name??"")}},[Q,O,y]),o.useEffect(()=>{c!==v&&!C.includes(c)&&u(v)},[C,c]);const U=ve,b=Ye({viewKey:"beads",rows:U,projectOf:Qe,searchOf:yt,chips:ye}),{toggleChip:ge}=b,Se=o.useCallback(t=>{t===we&&h(f=>!f),ge(t)},[ge]);ze([Ke.bead],()=>{E()},{coalesceMs:bt}),o.useEffect(()=>{r!==null&&m(r)},[r]);const se=o.useCallback(async(t,f,x)=>{if(!s){re({id:t.id,action:f}),S(null);try{if(f==="close")await gt(t.id,x),R(null),_(""),S({tone:"ok",text:`Closed ${t.id}.`});else{const j=t.assignee?.trim()??"";if(j.length===0)throw new Error("Assigned agent is required before nudging.");await ht(j),S({tone:"ok",text:`Nudged ${j}.`})}await E()}catch(j){S({tone:"error",text:fe(j,`${f} failed`)})}finally{re(null)}}},[s,E]),Ie=o.useCallback(()=>{const t=C[0]??"",f=N.find(x=>t.length===0||B(x)===t);ce(""),de(""),ue(t),$(f?.name??""),W(null),S(null),T(!0)},[N,B,C]),Be=o.useCallback(t=>{if(ue(t),!N.some(x=>x.name===y&&(t.length===0||B(x)===t))){const x=N.find(j=>t.length===0||B(j)===t);$(x?.name??"")}},[N,B,y]),Re=o.useCallback(async()=>{if(!s){oe(!0),W(null);try{const t=await ft({title:q,description:Z,rig:A,target:y});S({tone:"ok",text:`Created ${t.bead.id} and slung to ${y}.`}),T(!1),await E()}catch(t){W(fe(t,"create and sling failed"))}finally{oe(!1)}}},[y,Z,A,q,s,E]),P=o.useMemo(()=>b.groups.flatMap(t=>t.rows),[b.groups]),ne=o.useMemo(()=>rt(P),[P]),Ae=o.useMemo(()=>{const t=new Map;for(const f of b.groups)t.set(f.projectKey,new Set(f.rows.map(x=>x.id)));return t},[b.groups]),Ee=o.useMemo(()=>P.find(t=>t.id===p)??null,[P,p]),_e=o.useMemo(()=>p===null?null:ne.nodes.get(p)??null,[ne,p]),$e=o.useMemo(()=>t=>Ge(n,"beads",t),[n]),Me=o.useCallback(t=>{const f=t.assignee?.trim()??"",x=k!==null,j=k?.id===t.id?k.action.replace("_"," "):null,he=s?J:void 0;return e.jsxs("div",{className:"flex flex-wrap items-center justify-end gap-2",children:[s&&e.jsx(xe,{}),j&&e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint",children:j}),e.jsx(w,{type:"button",size:"sm",tone:"quiet",title:he,disabled:s||x||t.status==="closed",onClick:()=>{_(""),S(null),R(t)},children:"Close"}),e.jsx(w,{type:"button",size:"sm",tone:"quiet",title:he,disabled:s||x||f.length===0,onClick:()=>{se(t,"nudge")},children:"Nudge"})]})},[k,s,se]),Oe=o.useMemo(()=>H?Nt(U,pe,c):"Loading beads.",[U,H,pe,c]),Pe=typeof ee=="number"&&typeof te=="number"&&te{E()},disabled:z,children:z&&!H?"Loading":z?"Refreshing":"Refresh"})]})}),e.jsxs("div",{className:"space-y-2 mb-6 text-body text-fg-muted max-w-prose",children:[Pe&&e.jsx("p",{className:"text-warn",children:e.jsx(je,{tone:"warn",label:`Fetch window covered ${te} of ${ee} store beads. Raise the fetch limit (currently ${Ce??"?"}) if engineering work sits past the window.`})}),c!==v&&e.jsxs("p",{children:["Filtering by rig ",e.jsx("span",{className:"text-accent",children:c}),"."," ",e.jsx("button",{type:"button",onClick:()=>u(v),className:"text-fg-muted hover:text-fg focus-mark underline decoration-dotted underline-offset-2 rounded-sm",children:"Clear"})]}),F&&e.jsx("p",{className:F.tone==="error"?"text-accent":"text-fg-muted",role:F.tone==="error"?"alert":"status",children:F.text})]}),e.jsx(tt,{items:n.byDomain.beads.items,onOpen:m}),e.jsxs("div",{className:"mb-6 space-y-3",children:[e.jsx(Xe,{value:b.search,onChange:b.setSearch,placeholder:"Search beads by id, title, label, assignee",matchCount:b.totalMatches,totalCount:U.length,ariaLabel:"Search beads"}),e.jsxs("div",{className:"flex flex-wrap items-baseline gap-x-8 gap-y-3",children:[e.jsx(Je,{chips:ye,activeIds:b.activeChipIds,onToggle:Se,legend:"Status"}),C.length>1&&e.jsxs("label",{className:"flex items-baseline gap-2 text-label",children:[e.jsx("span",{className:"uppercase tracking-wider text-fg-muted",children:"Rig"}),e.jsxs("select",{value:c,onChange:t=>u(t.target.value),"aria-label":"Rig filter",className:"text-label uppercase tracking-wider text-fg-muted bg-transparent border-0 focus-mark cursor-pointer hover:text-fg transition-colors duration-150 ease-out-quart",children:[e.jsx("option",{value:v,children:"all rigs"}),C.map(t=>e.jsx("option",{value:t,children:t},t))]})]})]})]}),!H&&z?e.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading beads."}):P.length===0?e.jsx("p",{className:"text-body text-fg-muted italic",children:b.search.length>0||b.activeChipIds.size>0?"No beads match the current search or filter.":"Nothing on the queue right now."}):e.jsx("div",{className:"space-y-12",children:b.groups.map(t=>e.jsx(dt,{label:t.project,count:t.totalInProject,graph:ne,ids:Ae.get(t.projectKey)??xt,selectedId:p,attentionSeverity:$e,onSelect:m},t.projectKey))}),e.jsx(Ue,{open:p!==null,onClose:()=>m(null),beadId:p,initialBead:Ee,depNode:_e,sessions:ke,onOpenBead:m,renderActions:Me}),e.jsx(be,{open:g!==null,onClose:()=>{k===null&&(R(null),_(""))},title:g?`Close ${g.id}`:"Close bead",caption:g?.title,widthClass:"max-w-xl",footer:e.jsxs(e.Fragment,{children:[e.jsx(w,{type:"button",size:"sm",tone:"quiet",disabled:k!==null,onClick:()=>{R(null),_("")},children:"Cancel"}),e.jsx(w,{type:"button",size:"sm",tone:"accent",title:s?J:void 0,disabled:s||g===null||k!==null,onClick:()=>{g&&se(g,"close",le)},children:"Close bead"})]}),children:e.jsxs("label",{className:"block space-y-2 text-body",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Reason"}),e.jsx("textarea",{value:le,onChange:t=>_(t.target.value),rows:4,placeholder:"Optional close reason",className:"w-full rounded-sm border border-rule bg-transparent px-3 py-2 text-body text-fg focus-mark"})]})}),e.jsx(be,{open:Q,onClose:()=>{D||T(!1)},title:"New bead",caption:"Create and sling",widthClass:"max-w-2xl",footer:e.jsxs(e.Fragment,{children:[e.jsx(w,{type:"button",size:"sm",tone:"quiet",disabled:D,onClick:()=>T(!1),children:"Cancel"}),e.jsx(w,{type:"submit",form:"new-bead-form",size:"sm",title:s?J:void 0,disabled:s||D||q.trim().length===0||y.trim().length===0,children:D?"Creating":"Create and sling"})]}),children:e.jsxs("form",{id:"new-bead-form",className:"space-y-5",onSubmit:t=>{t.preventDefault(),Re()},children:[ie&&e.jsx("p",{className:"text-accent",role:"alert",children:ie}),e.jsxs("label",{className:"block space-y-2 text-body",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Title"}),e.jsx("input",{value:q,onChange:t=>ce(t.target.value),required:!0,className:"w-full rounded-sm border border-rule bg-transparent px-3 py-2 text-body text-fg focus-mark"})]}),e.jsxs("label",{className:"block space-y-2 text-body",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Body"}),e.jsx("textarea",{value:Z,onChange:t=>de(t.target.value),rows:5,className:"w-full rounded-sm border border-rule bg-transparent px-3 py-2 text-body text-fg focus-mark"})]}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsxs("label",{className:"block space-y-2 text-body",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Rig"}),e.jsxs("select",{value:A,onChange:t=>Be(t.target.value),className:"w-full rounded-sm border border-rule bg-transparent px-3 py-2 text-body text-fg focus-mark",children:[C.length===0&&e.jsx("option",{value:"",children:"all rigs"}),C.map(t=>e.jsx("option",{value:t,children:t},t))]})]}),e.jsxs("label",{className:"block space-y-2 text-body",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Agent"}),e.jsx("select",{value:y,onChange:t=>$(t.target.value),required:!0,className:"w-full rounded-sm border border-rule bg-transparent px-3 py-2 text-body text-fg focus-mark",children:O.map(t=>e.jsx("option",{value:t.name,children:t.display_name??t.name},t.name))})]})]})]})})]})}function jt(n){const s=n?.trim();return s&&s.length>0?s:null}function Nt(n,s,l){if(l!==v&&n.length===0)return`No beads on ${l}.`;const a=n.filter(d=>d.status==="open").length,i=n.filter(d=>d.status==="in_progress").length,r=n.filter(d=>d.status==="blocked").length,c=[];if(a>0&&c.push(`${a} open`),i>0&&c.push(`${i} in progress`),r>0&&c.push(`${r} blocked`),c.length===0)return"Nothing on the queue.";let u=`${c.join(", ")}.`;return l!==v&&(u=`${l}: ${u}`),s>n.length&&(u+=` Showing ${n.length} of ${s}.`),u}export{Mt as BeadsPage}; +import{j as e,S as je,B as w,r as o,I as L,J as X,a as Le,g as Fe,K as Te,b as Y,c as De,l as qe,f as ze,z as fe,R as xe,i as J,H as He,G as Ke}from"./index-C20tCZFz.js";import{b as Ve,r as Ge}from"./routeHighlight-B30gQO2o.js";import{B as Ue}from"./BeadDetailModal-BtVrX_Fu.js";import{u as Ye,F as Je}from"./useListFilters-C0Eq1DLc.js";import{L as Xe,f as Qe}from"./projectOf-CwPPScnJ.js";import{M as be}from"./constants-DBKWGg29.js";import{P as We}from"./PageHeader-D_D-jYn1.js";import{l as Ze}from"./agentReads-C0EYRgYm.js";import"./format-fte2CeYD.js";import"./Field-Dsl4x4KL.js";import"./LiveSessionPeek-jm19JJ4Z.js";import"./time-D9v0saHV.js";function et(n){if(n===void 0)return null;const s=n.indexOf("?");if(s<0)return null;const l=new URLSearchParams(n.slice(s+1)).get("bead");return l!==null&&l.length>0?l:null}function tt({items:n,onOpen:s}){const l=n.filter(a=>a.severity==="attention"||a.severity==="watch");return l.length===0?null:e.jsxs("section",{"aria-labelledby":"beads-attention-title",className:"mb-8 space-y-3",children:[e.jsxs("h2",{id:"beads-attention-title",className:"text-label uppercase tracking-wider text-fg-muted",children:["Needs you ",e.jsxs("span",{className:"tnum text-fg",children:["(",l.length,")"]})]}),e.jsx("ul",{className:"space-y-2",children:l.map(a=>{const i=et(a.href);return e.jsxs("li",{className:"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1",children:[e.jsxs("div",{className:"min-w-0 space-y-0.5",children:[e.jsx(je,{tone:a.severity==="attention"?"stuck":"warn",label:a.title}),a.summary!==void 0&&e.jsx("p",{className:"text-body text-fg-muted",children:a.summary})]}),i!==null&&e.jsx("div",{className:"flex items-center gap-2",children:e.jsx(w,{type:"button",size:"sm",tone:"quiet",onClick:()=>s(i),children:"Open"})})]},a.id)})})]})}const ae=[{id:"ready",label:"ready"},{id:"open",label:"open"},{id:"in_progress",label:"in progress"},{id:"blocked",label:"blocked"},{id:"done",label:"done"}];function st(n){const s=new Set,l=[];for(const a of n.needs??[])a.length===0||s.has(a)||(s.add(a),l.push({id:a,kind:"needs"}));for(const a of n.dependencies??[]){const i=a.depends_on_id;i.length===0||s.has(i)||(s.add(i),l.push({id:i,kind:a.type}))}return l}function nt(n){return(n.needs??[]).filter(s=>s.length>0)}function at(n){switch(n.bead.status){case"in_progress":return"in_progress";case"blocked":return"blocked";case"closed":return"done";default:return n.ready?"ready":"open"}}function lt(n,s){const l=n.bead.priority??Number.POSITIVE_INFINITY,a=s.bead.priority??Number.POSITIVE_INFINITY;return l!==a?l-a:n.bead.ids.bead.id?1:0}function rt(n){const s=new Map;for(const r of n)s.set(r.id,r);const l=new Map,a=new Map;for(const r of n){const c=st(r).map(({id:m,kind:g})=>({id:m,kind:g,bead:s.get(m)??null})),u=c.some(m=>m.bead===null),d=nt(r),h=r.status==="open"&&d.every(m=>s.get(m)?.status==="closed"),p={bead:r,deps:c,blocks:[],ready:h,hasUnresolvedDeps:u,column:"open"};p.column=at(p),a.set(r.id,p);for(const m of c){if(m.bead===null)continue;const g=l.get(m.id);g?g.push(r):l.set(m.id,[r])}}for(const[r,c]of l){const u=a.get(r);u&&(u.blocks=[...c].sort((d,h)=>d.idh.id?1:0))}const i=Ne();for(const r of a.values())i[r.column].push(r);for(const r of ae)i[r.id].sort(lt);return{nodes:a,columns:i}}function Ne(){return{ready:[],open:[],in_progress:[],blocked:[],done:[]}}function ot(n,s){const l=Ne();for(const a of ae)l[a.id]=n.columns[a.id].filter(i=>s.has(i.bead.id));return l}function it({node:n,selected:s,attentionSeverity:l=null,onSelect:a}){const{bead:i,deps:r,blocks:c,hasUnresolvedDeps:u}=n,d=o.useRef(null),h=r.length,p=c.length,m=h>0||p>0,{className:g="",...R}=Ve(l);return o.useEffect(()=>{s&&d.current?.scrollIntoView?.({block:"center",inline:"nearest"})},[s]),e.jsx("li",{ref:d,...R,className:`px-2 py-2 -mx-2 rounded-sm transition-colors duration-150 ease-out-quart ${s?"bg-surface-tint":"hover:bg-surface-tint/60"} ${g}`,children:e.jsxs("button",{type:"button",onClick:()=>a(i.id),className:"text-left w-full focus-mark rounded-sm","aria-pressed":s,title:`Select ${i.id}`,children:[e.jsxs("span",{className:"flex items-baseline gap-2",children:[e.jsx("span",{className:"text-fg-faint","aria-hidden":"true",children:s?"▸":" "}),e.jsx("span",{className:`min-w-0 line-clamp-2 text-body ${s?"text-fg font-medium":"text-fg"}`,children:i.title})]}),e.jsxs("span",{className:"flex items-baseline gap-3 pl-4 mt-0.5 text-label uppercase tracking-wider text-fg-faint",children:[e.jsx("span",{className:"tnum",children:i.id}),i.priority!=null&&e.jsxs("span",{className:"tnum",children:["P",i.priority]}),m&&e.jsxs("span",{className:"tnum normal-case tracking-normal",children:[h>0&&`needs ${h}`,h>0&&p>0&&" · ",p>0&&`blocks ${p}`]}),u&&e.jsx("span",{className:"normal-case tracking-normal text-warn",children:"unresolved"})]})]})})}function ct({columns:n,selectedId:s,attentionSeverity:l,onSelect:a}){return e.jsx("div",{className:"grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-x-8 gap-y-8",children:ae.map(i=>{const r=n[i.id],u=i.id==="blocked"&&r.length>0?"text-accent":"text-fg-muted";return e.jsxs("section",{"aria-label":i.label,children:[e.jsxs("header",{className:"flex items-baseline justify-between border-b border-rule pb-2 mb-3",children:[e.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-muted",children:i.label}),e.jsx("span",{className:`text-label tnum ${u}`,children:r.length})]}),r.length===0?e.jsx("p",{className:"text-body text-fg-faint italic",children:"·"}):e.jsx("ul",{className:"space-y-1",children:r.map(d=>e.jsx(it,{node:d,selected:d.bead.id===s,attentionSeverity:l?.(d.bead.id)??null,onSelect:a},d.bead.id))})]},i.id)})})}function dt({label:n,count:s,graph:l,ids:a,selectedId:i,attentionSeverity:r,onSelect:c}){const u=ot(l,a);return e.jsxs("section",{"aria-label":n,children:[e.jsxs("header",{className:"flex items-baseline justify-between border-b border-rule pb-2 mb-4",children:[e.jsx("h2",{className:"text-headline text-fg",children:n}),e.jsx("span",{className:"text-label tnum text-fg-muted",children:s})]}),e.jsx(ct,{columns:u,selectedId:i,...r===void 0?{}:{attentionSeverity:r},onSelect:c})]})}function ut(n,s){const l=n?.trim();if(!l)return;const a=s.find(r=>r.name===l);return a?a.name:s.find(r=>r.path===l)?.name}function mt(n){return Array.from(new Set(n.map(s=>s.name.trim()).filter(s=>s.length>0))).sort((s,l)=>s.localeCompare(l))}async function pt(){const n=await L().listRigs(X("list supervisor rigs"));return{...n,items:n.items??[]}}async function gt(n,s){const l=s?.trim()??"";await L().closeBead(X("close supervisor bead"),n,l.length===0?void 0:{reason:l})}async function ht(n){const s=n.trim();if(s.length===0)throw new Error("agent alias is required");await L().nudgeAgent(X("nudge supervisor agent"),s)}async function ft(n){const s=n.title.trim(),l=n.description.trim(),a=n.rig.trim(),i=n.target.trim();if(s.length===0)throw new Error("bead title is required");if(i.length===0)throw new Error("sling target is required");const r=X("create and sling supervisor bead"),c={title:s};l.length>0&&(c.description=l);const u=await L().createBead(r,c),d={bead:u.id,target:i};a.length>0&&(d.rig=a);const h=await L().sling(r,d);return{bead:u,sling:h}}const xt=new Set,v="",we="closed",bt=1e4,ye=[{id:"open",label:"open",match:n=>n.status==="open"},{id:"in_progress",label:"in progress",match:n=>n.status==="in_progress"},{id:"blocked",label:"blocked",match:n=>n.status==="blocked"},{id:we,label:"closed",match:n=>n.status==="closed"}],yt=n=>[n.id,n.title,n.assignee,...n.labels??[]];function Mt(){const n=Le(),s=Fe(),a=He()??"no-city",[i]=Te(),r=jt(i.get("bead")),[c,u]=o.useState(v),[d,h]=o.useState(!1),[p,m]=o.useState(r),[g,R]=o.useState(null),[le,_]=o.useState(""),[k,re]=o.useState(null),[F,S]=o.useState(null),[Q,T]=o.useState(!1),[D,oe]=o.useState(!1),[ie,W]=o.useState(null),[q,ce]=o.useState(""),[Z,de]=o.useState(""),[A,ue]=o.useState(""),[y,$]=o.useState(""),{data:I,loading:z,error:me,refresh:E}=Y(`beads:board:${a}:${c}:${d?"all":"open"}`,()=>De({includeClosed:d,...c===v?{}:{rigFilter:c}})),ve=o.useMemo(()=>I?.items??[],[I]),pe=I?.total??0,ee=I?.upstream_total,te=I?.upstream_fetched,Ce=I?.fetch_limit,H=I!==void 0,K=Y(`sessions:${a}`,qe),ke=o.useMemo(()=>K.data?.items??[],[K.data]),M=Y(`agents:${a}`,Ze),N=o.useMemo(()=>M.data?.items??[],[M.data]),V=Y(`rigs:${a}`,pt),G=o.useMemo(()=>V.data?.items??[],[V.data]),C=o.useMemo(()=>mt(G),[G]),B=o.useCallback(t=>ut(t.rig,G),[G]),O=o.useMemo(()=>A.length===0?N:N.filter(t=>B(t)===A),[N,B,A]);o.useEffect(()=>{if(Q){if(O.length===0){y.length>0&&$("");return}O.some(t=>t.name===y)||$(O[0]?.name??"")}},[Q,O,y]),o.useEffect(()=>{c!==v&&!C.includes(c)&&u(v)},[C,c]);const U=ve,b=Ye({viewKey:"beads",rows:U,projectOf:Qe,searchOf:yt,chips:ye}),{toggleChip:ge}=b,Se=o.useCallback(t=>{t===we&&h(f=>!f),ge(t)},[ge]);ze([Ke.bead],()=>{E()},{coalesceMs:bt}),o.useEffect(()=>{r!==null&&m(r)},[r]);const se=o.useCallback(async(t,f,x)=>{if(!s){re({id:t.id,action:f}),S(null);try{if(f==="close")await gt(t.id,x),R(null),_(""),S({tone:"ok",text:`Closed ${t.id}.`});else{const j=t.assignee?.trim()??"";if(j.length===0)throw new Error("Assigned agent is required before nudging.");await ht(j),S({tone:"ok",text:`Nudged ${j}.`})}await E()}catch(j){S({tone:"error",text:fe(j,`${f} failed`)})}finally{re(null)}}},[s,E]),Ie=o.useCallback(()=>{const t=C[0]??"",f=N.find(x=>t.length===0||B(x)===t);ce(""),de(""),ue(t),$(f?.name??""),W(null),S(null),T(!0)},[N,B,C]),Be=o.useCallback(t=>{if(ue(t),!N.some(x=>x.name===y&&(t.length===0||B(x)===t))){const x=N.find(j=>t.length===0||B(j)===t);$(x?.name??"")}},[N,B,y]),Re=o.useCallback(async()=>{if(!s){oe(!0),W(null);try{const t=await ft({title:q,description:Z,rig:A,target:y});S({tone:"ok",text:`Created ${t.bead.id} and slung to ${y}.`}),T(!1),await E()}catch(t){W(fe(t,"create and sling failed"))}finally{oe(!1)}}},[y,Z,A,q,s,E]),P=o.useMemo(()=>b.groups.flatMap(t=>t.rows),[b.groups]),ne=o.useMemo(()=>rt(P),[P]),Ae=o.useMemo(()=>{const t=new Map;for(const f of b.groups)t.set(f.projectKey,new Set(f.rows.map(x=>x.id)));return t},[b.groups]),Ee=o.useMemo(()=>P.find(t=>t.id===p)??null,[P,p]),_e=o.useMemo(()=>p===null?null:ne.nodes.get(p)??null,[ne,p]),$e=o.useMemo(()=>t=>Ge(n,"beads",t),[n]),Me=o.useCallback(t=>{const f=t.assignee?.trim()??"",x=k!==null,j=k?.id===t.id?k.action.replace("_"," "):null,he=s?J:void 0;return e.jsxs("div",{className:"flex flex-wrap items-center justify-end gap-2",children:[s&&e.jsx(xe,{}),j&&e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint",children:j}),e.jsx(w,{type:"button",size:"sm",tone:"quiet",title:he,disabled:s||x||t.status==="closed",onClick:()=>{_(""),S(null),R(t)},children:"Close"}),e.jsx(w,{type:"button",size:"sm",tone:"quiet",title:he,disabled:s||x||f.length===0,onClick:()=>{se(t,"nudge")},children:"Nudge"})]})},[k,s,se]),Oe=o.useMemo(()=>H?Nt(U,pe,c):"Loading beads.",[U,H,pe,c]),Pe=typeof ee=="number"&&typeof te=="number"&&te{E()},disabled:z,children:z&&!H?"Loading":z?"Refreshing":"Refresh"})]})}),e.jsxs("div",{className:"space-y-2 mb-6 text-body text-fg-muted max-w-prose",children:[Pe&&e.jsx("p",{className:"text-warn",children:e.jsx(je,{tone:"warn",label:`Fetch window covered ${te} of ${ee} store beads. Raise the fetch limit (currently ${Ce??"?"}) if engineering work sits past the window.`})}),c!==v&&e.jsxs("p",{children:["Filtering by rig ",e.jsx("span",{className:"text-accent",children:c}),"."," ",e.jsx("button",{type:"button",onClick:()=>u(v),className:"text-fg-muted hover:text-fg focus-mark underline decoration-dotted underline-offset-2 rounded-sm",children:"Clear"})]}),F&&e.jsx("p",{className:F.tone==="error"?"text-accent":"text-fg-muted",role:F.tone==="error"?"alert":"status",children:F.text})]}),e.jsx(tt,{items:n.byDomain.beads.items,onOpen:m}),e.jsxs("div",{className:"mb-6 space-y-3",children:[e.jsx(Xe,{value:b.search,onChange:b.setSearch,placeholder:"Search beads by id, title, label, assignee",matchCount:b.totalMatches,totalCount:U.length,ariaLabel:"Search beads"}),e.jsxs("div",{className:"flex flex-wrap items-baseline gap-x-8 gap-y-3",children:[e.jsx(Je,{chips:ye,activeIds:b.activeChipIds,onToggle:Se,legend:"Status"}),C.length>1&&e.jsxs("label",{className:"flex items-baseline gap-2 text-label",children:[e.jsx("span",{className:"uppercase tracking-wider text-fg-muted",children:"Rig"}),e.jsxs("select",{value:c,onChange:t=>u(t.target.value),"aria-label":"Rig filter",className:"text-label uppercase tracking-wider text-fg-muted bg-transparent border-0 focus-mark cursor-pointer hover:text-fg transition-colors duration-150 ease-out-quart",children:[e.jsx("option",{value:v,children:"all rigs"}),C.map(t=>e.jsx("option",{value:t,children:t},t))]})]})]})]}),!H&&z?e.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading beads."}):P.length===0?e.jsx("p",{className:"text-body text-fg-muted italic",children:b.search.length>0||b.activeChipIds.size>0?"No beads match the current search or filter.":"Nothing on the queue right now."}):e.jsx("div",{className:"space-y-12",children:b.groups.map(t=>e.jsx(dt,{label:t.project,count:t.totalInProject,graph:ne,ids:Ae.get(t.projectKey)??xt,selectedId:p,attentionSeverity:$e,onSelect:m},t.projectKey))}),e.jsx(Ue,{open:p!==null,onClose:()=>m(null),beadId:p,initialBead:Ee,depNode:_e,sessions:ke,onOpenBead:m,renderActions:Me}),e.jsx(be,{open:g!==null,onClose:()=>{k===null&&(R(null),_(""))},title:g?`Close ${g.id}`:"Close bead",caption:g?.title,widthClass:"max-w-xl",footer:e.jsxs(e.Fragment,{children:[e.jsx(w,{type:"button",size:"sm",tone:"quiet",disabled:k!==null,onClick:()=>{R(null),_("")},children:"Cancel"}),e.jsx(w,{type:"button",size:"sm",tone:"accent",title:s?J:void 0,disabled:s||g===null||k!==null,onClick:()=>{g&&se(g,"close",le)},children:"Close bead"})]}),children:e.jsxs("label",{className:"block space-y-2 text-body",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Reason"}),e.jsx("textarea",{value:le,onChange:t=>_(t.target.value),rows:4,placeholder:"Optional close reason",className:"w-full rounded-sm border border-rule bg-transparent px-3 py-2 text-body text-fg focus-mark"})]})}),e.jsx(be,{open:Q,onClose:()=>{D||T(!1)},title:"New bead",caption:"Create and sling",widthClass:"max-w-2xl",footer:e.jsxs(e.Fragment,{children:[e.jsx(w,{type:"button",size:"sm",tone:"quiet",disabled:D,onClick:()=>T(!1),children:"Cancel"}),e.jsx(w,{type:"submit",form:"new-bead-form",size:"sm",title:s?J:void 0,disabled:s||D||q.trim().length===0||y.trim().length===0,children:D?"Creating":"Create and sling"})]}),children:e.jsxs("form",{id:"new-bead-form",className:"space-y-5",onSubmit:t=>{t.preventDefault(),Re()},children:[ie&&e.jsx("p",{className:"text-accent",role:"alert",children:ie}),e.jsxs("label",{className:"block space-y-2 text-body",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Title"}),e.jsx("input",{value:q,onChange:t=>ce(t.target.value),required:!0,className:"w-full rounded-sm border border-rule bg-transparent px-3 py-2 text-body text-fg focus-mark"})]}),e.jsxs("label",{className:"block space-y-2 text-body",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Body"}),e.jsx("textarea",{value:Z,onChange:t=>de(t.target.value),rows:5,className:"w-full rounded-sm border border-rule bg-transparent px-3 py-2 text-body text-fg focus-mark"})]}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsxs("label",{className:"block space-y-2 text-body",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Rig"}),e.jsxs("select",{value:A,onChange:t=>Be(t.target.value),className:"w-full rounded-sm border border-rule bg-transparent px-3 py-2 text-body text-fg focus-mark",children:[C.length===0&&e.jsx("option",{value:"",children:"all rigs"}),C.map(t=>e.jsx("option",{value:t,children:t},t))]})]}),e.jsxs("label",{className:"block space-y-2 text-body",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Agent"}),e.jsx("select",{value:y,onChange:t=>$(t.target.value),required:!0,className:"w-full rounded-sm border border-rule bg-transparent px-3 py-2 text-body text-fg focus-mark",children:O.map(t=>e.jsx("option",{value:t.name,children:t.display_name??t.name},t.name))})]})]})]})})]})}function jt(n){const s=n?.trim();return s&&s.length>0?s:null}function Nt(n,s,l){if(l!==v&&n.length===0)return`No beads on ${l}.`;const a=n.filter(d=>d.status==="open").length,i=n.filter(d=>d.status==="in_progress").length,r=n.filter(d=>d.status==="blocked").length,c=[];if(a>0&&c.push(`${a} open`),i>0&&c.push(`${i} in progress`),r>0&&c.push(`${r} blocked`),c.length===0)return"Nothing on the queue.";let u=`${c.join(", ")}.`;return l!==v&&(u=`${l}: ${u}`),s>n.length&&(u+=` Showing ${n.length} of ${s}.`),u}export{Mt as BeadsPage}; diff --git a/internal/api/dashboardspa/dist/assets/Field-BpdGqWpv.js b/internal/api/dashboardspa/dist/assets/Field-Dsl4x4KL.js similarity index 85% rename from internal/api/dashboardspa/dist/assets/Field-BpdGqWpv.js rename to internal/api/dashboardspa/dist/assets/Field-Dsl4x4KL.js index 43b7a5a99e..8fa23d4197 100644 --- a/internal/api/dashboardspa/dist/assets/Field-BpdGqWpv.js +++ b/internal/api/dashboardspa/dist/assets/Field-Dsl4x4KL.js @@ -1 +1 @@ -import{j as e}from"./index-BFDP6Xwd.js";function i({label:t,children:s,variant:a="definition"}){return a==="form"?e.jsxs("label",{className:"block space-y-1.5",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:t}),s]}):e.jsxs("div",{children:[e.jsx("dt",{className:"text-label uppercase tracking-wider text-fg-faint mb-1",children:t}),e.jsx("dd",{className:"text-body text-fg",children:s})]})}export{i as F}; +import{j as e}from"./index-C20tCZFz.js";function i({label:t,children:s,variant:a="definition"}){return a==="form"?e.jsxs("label",{className:"block space-y-1.5",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:t}),s]}):e.jsxs("div",{children:[e.jsx("dt",{className:"text-label uppercase tracking-wider text-fg-faint mb-1",children:t}),e.jsx("dd",{className:"text-body text-fg",children:s})]})}export{i as F}; diff --git a/internal/api/dashboardspa/dist/assets/FormulaRunDetail-BIoITriX.js b/internal/api/dashboardspa/dist/assets/FormulaRunDetail-BIoITriX.js deleted file mode 100644 index a30cadca31..0000000000 --- a/internal/api/dashboardspa/dist/assets/FormulaRunDetail-BIoITriX.js +++ /dev/null @@ -1,12 +0,0 @@ -import{j as d,r as j,S as Er,X as Me,Y as Ke,Z as Dr,_ as Tr,x as nn,p as rn,b as Xn,q as Or,K as Mr,f as Ir,u as Rr,$ as Pr,L as $r,B as Fr,H as Br,G as yn}from"./index-BFDP6Xwd.js";import{P as Gr}from"./PageHeader-5RHLpIfH.js";import{u as xr,R as Lr,B as Ur}from"./BeadDetailModal-BKOlUSQL.js";import{u as zr,S as Kr}from"./LiveSessionPeek-Cjv3DcC3.js";import{S as wn}from"./StageLadder-BIFUAoAh.js";import"./format-fte2CeYD.js";import"./Field-BpdGqWpv.js";import"./constants-DOaI3lZl.js";import"./time-D9v0saHV.js";const Hr=/^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,127}$/,_n={pending:"pending",ready:"ready",running:"running",active:"running",done:"done",completed:"done",failed:"failed",blocked:"blocked",skipped:"skipped"};function Vr({node:e,selected:r,onToggle:n}){const t=Zr(e.constructKind),a=Yr(e.status),s=e.iterationSummary.kind==="stacked"?`${e.iterationSummary.iterationCount} iterations, showing ${e.iterationSummary.visibleIteration}`:null,i=e.attemptSummary.kind==="tracked"&&e.attemptSummary.badge.kind==="bounded"?` · attempt ${e.attemptSummary.badge.label}${Wr(e)}`:"";return d.jsxs("button",{type:"button","aria-pressed":r,onClick:()=>n(e.id),className:`focus-mark w-full text-left px-4 py-3 bg-transparent transition-colors duration-150 ease-out-quart ${t} ${r?"text-fg border-accent bg-surface-tint ring-2 ring-accent/45 ring-offset-2 ring-offset-surface":"text-fg border-rule hover:border-fg-faint hover:bg-surface-tint"}`,children:[d.jsxs("div",{className:"flex items-start justify-between gap-3",children:[d.jsxs("div",{children:[d.jsx("p",{className:"text-body text-fg leading-snug",children:e.title}),d.jsxs("p",{className:"mt-1 text-label uppercase tracking-wider text-fg-faint",children:[Xr(e.constructKind),i]})]}),d.jsxs("span",{className:`text-label uppercase tracking-wider shrink-0 ${a}`,children:[Jr(e.status)," ",_n[e.status]]})]}),s&&d.jsxs("p",{className:"mt-2 text-label uppercase tracking-wider text-fg-faint tnum",children:["stacked history: ",s]}),e.controlBadges.length>0&&d.jsx("div",{className:"mt-2 flex flex-wrap gap-2",children:e.controlBadges.map(l=>d.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-muted border border-rule px-1.5 py-0.5",children:[l.label,": ",_n[l.status]]},l.id))})]})}function Wr(e){return e.attemptSummary.kind==="tracked"&&e.attemptSummary.active.kind==="running"?` · running attempt ${e.attemptSummary.active.value}`:""}function Xr(e){switch(e){case"run-root":return"run root";case"run-finalize":return"finalize";case"step":case"retry":case"check-loop":case"scope":case"condition":case"fanout":case"expansion":case"scope-check":case"spec":case"control":case"unknown":return e.replace(/-/g," ")}}function Zr(e){switch(e){case"run-root":return"formula-run-node-shape-root";case"step":case"unknown":return"formula-run-node-shape-step";case"retry":return"formula-run-node-shape-retry";case"check-loop":return"formula-run-node-shape-check-loop";case"scope":return"formula-run-node-shape-scope";case"condition":return"formula-run-node-shape-condition";case"fanout":return"formula-run-node-shape-fanout";case"expansion":return"formula-run-node-shape-expansion";case"scope-check":case"run-finalize":case"spec":case"control":return"formula-run-node-shape-control"}}function Yr(e){switch(e){case"failed":case"blocked":return"text-accent";case"active":case"running":case"ready":return"text-fg";case"completed":case"done":return"text-fg-muted";case"pending":case"skipped":return"text-fg-faint"}}function Jr(e){switch(e){case"completed":case"done":return"✓";case"active":case"running":return"●";case"failed":case"blocked":return"!";case"skipped":return"∅";case"pending":case"ready":return"·"}}function Qr({detail:e,selectedNodeId:r,onToggleNode:n}){const t=qr(e),a=et(e);return t.length===0?d.jsx("p",{className:"text-body text-fg-muted italic",children:"No graph nodes have materialized for this formula run."}):d.jsxs("section",{"aria-label":"Formula run graph",children:[d.jsx("div",{className:"flex items-baseline justify-between gap-4",children:d.jsx("h2",{className:"text-title text-fg",children:"Formula Graph"})}),d.jsx("ol",{className:"mt-5 space-y-3 relative",children:t.map((s,i)=>{const l=a.get(s.id),o=i>0?a.get(t[i-1]?.id??""):void 0,u=l!==void 0&&l!==o;return d.jsxs("li",{className:"relative pl-6",children:[u&&d.jsx("p",{className:"mb-1 text-label uppercase tracking-wider text-fg-faint",children:l}),ir.visibleInGraph!==!1)}function et(e){const r=new Map;for(const n of e.lanes)for(const t of n.nodeIds)r.set(t,n.label);return r}function Nn(e,r){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var t=Object.getOwnPropertySymbols(e);r&&(t=t.filter((function(a){return Object.getOwnPropertyDescriptor(e,a).enumerable}))),n.push.apply(n,t)}return n}function M(e){for(var r=1;r=0||(c[o]=i[o]);return c})(e,r);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(e);for(t=0;t=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(a[n]=e[n])}return a}function x(e,r){return rt(e)||(function(n,t){var a=n==null?null:typeof Symbol<"u"&&n[Symbol.iterator]||n["@@iterator"];if(a!=null){var s,i,l,o,u=[],c=!0,f=!1;try{if(l=(a=a.call(n)).next,t===0){if(Object(a)!==a)return;c=!1}else for(;!(c=(s=l.call(a)).done)&&(u.push(s.value),u.length!==t);c=!0);}catch(h){f=!0,i=h}finally{try{if(!c&&a.return!=null&&(o=a.return(),Object(o)!==o))return}finally{if(f)throw i}}return u}})(e,r)||tn(e,r)||at()}function nt(e){return(function(r){if(Array.isArray(r))return Ve(r)})(e)||tt(e)||tn(e)||(function(){throw new TypeError(`Invalid attempt to spread non-iterable instance. -In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)})()}function rt(e){if(Array.isArray(e))return e}function tt(e){if(typeof Symbol<"u"&&e[Symbol.iterator]!=null||e["@@iterator"]!=null)return Array.from(e)}function tn(e,r){if(e){if(typeof e=="string")return Ve(e,r);var n=Object.prototype.toString.call(e).slice(8,-1);return n==="Object"&&e.constructor&&(n=e.constructor.name),n==="Map"||n==="Set"?Array.from(e):n==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?Ve(e,r):void 0}}function Ve(e,r){(r==null||r>e.length)&&(r=e.length);for(var n=0,t=new Array(r);n=e.length?{done:!0}:{done:!1,value:e[t++]}},e:function(o){throw o},f:a}}throw new TypeError(`Invalid attempt to iterate non-iterable instance. -In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}var s,i=!0,l=!1;return{s:function(){n=n.call(e)},n:function(){var o=n.next();return i=o.done,o},e:function(o){l=!0,s=o},f:function(){try{i||n.return==null||n.return()}finally{if(l)throw s}}}}var Se=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function ye(e,r){return e(r={exports:{}},r.exports),r.exports}var F=ye((function(e){(function(){var r={}.hasOwnProperty;function n(){for(var t=[],a=0;a-1?p.slice(0,y):_;switch(_){case"diff":v--;break e;case"deleted":case"new":var N=p.slice(y+1);N.indexOf("file mode")===0&&(i[_==="new"?"newMode":"oldMode"]=N.slice(10));break;case"similarity":i.similarity=parseInt(p.split(" ")[2],10);break;case"index":var C=p.slice(y+1).split(" "),S=C[0].split("..");i.oldRevision=S[0],i.newRevision=S[1],C[1]&&(i.oldMode=i.newMode=C[1]);break;case"copy":case"rename":var A=p.slice(y+1);A.indexOf("from")===0?i.oldPath=A.slice(5):i.newPath=A.slice(3),w=_;break;case"---":var k=p.slice(y+1),E=g[++v].slice(4);k==="/dev/null"?(E=E.slice(2),w="add"):E==="/dev/null"?(k=k.slice(2),w="delete"):(w="modify",k=k.slice(2),E=E.slice(2)),k&&(i.oldPath=k),E&&(i.newPath=E),h=5;break e}}i.type=w||"modify"}else if(b.indexOf("Binary")===0)i.isBinary=!0,i.type=b.indexOf("/dev/null and")>=0?"add":b.indexOf("and /dev/null")>=0?"delete":"modify",h=2,i=null;else if(h===5)if(b.indexOf("@@")===0){var D=/^@@\s+-([0-9]+)(,([0-9]+))?\s+\+([0-9]+)(,([0-9]+))?/.exec(b);l={content:b,oldStart:D[1]-0,newStart:D[4]-0,oldLines:D[3]-0||1,newLines:D[6]-0||1,changes:[]},i.hunks.push(l),o=l.oldStart,u=l.newStart}else{var G=b.slice(0,1),T={content:b.slice(1)};switch(G){case"+":T.type="insert",T.isInsert=!0,T.lineNumber=u,u++;break;case"-":T.type="delete",T.isDelete=!0,T.lineNumber=o,o++;break;case" ":T.type="normal",T.isNormal=!0,T.oldLineNumber=o,T.newLineNumber=u,o++,u++;break;case"\\":var $=l.changes[l.changes.length-1];$.isDelete||(i.newEndingNewLine=!1),$.isInsert||(i.oldEndingNewLine=!1)}T.type&&l.changes.push(T)}v++}return f}};e.exports=a})()}));function we(e){return e.type==="insert"}function J(e){return e.type==="delete"}function ge(e){return e.type==="normal"}function ut(e,r){var n=r.nearbySequences==="zip"?(function(t){var a=t.reduce((function(s,i,l){var o=x(s,3),u=o[0],c=o[1],f=o[2];return c?we(i)&&f>=0?(u.splice(f+1,0,i),[u,i,f+2]):(u.push(i),[u,i,J(i)&&J(c)?f:l]):(u.push(i),[u,i,J(i)?l:-1])}),[[],null,-1]);return x(a,1)[0]})(e.changes):e.changes;return M(M({},e),{},{isPlain:!1,changes:n})}function ct(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},n=(function(t){if(t.startsWith("diff --git"))return t;var a=t.indexOf(` -`),s=t.indexOf(` -`,a+1),i=t.slice(0,a),l=t.slice(a+1,s),o=i.split(" ").slice(1,-3).join(" "),u=l.split(" ").slice(1,-3).join(" ");return["diff --git a/".concat(o," b/").concat(u),"index 1111111..2222222 100644","--- a/".concat(o),"+++ b/".concat(u),t.slice(s+1)].join(` -`)})(e.trimStart());return lt.parse(n).map((function(t){return(function(a,s){var i=a.hunks.map((function(l){return ut(l,s)}));return M(M({},a),{},{hunks:i})})(t,r)}))}function ft(e){return e[0]}function dt(e){return e[e.length-1]}function We(e){return["".concat(e,"Start"),"".concat(e,"Lines")]}function me(e){return e==="old"?function(r){return we(r)?-1:ge(r)?r.oldLineNumber:r.lineNumber}:function(r){return J(r)?-1:ge(r)?r.newLineNumber:r.lineNumber}}function Yn(e,r){return function(n,t){var a=n[e],s=a+n[r];return t>=a&&t=s&&a-1},yt=function(e,r){var n=this.__data__,t=Ie(n,e);return t<0?(++this.size,n.push([e,r])):n[t][1]=r,this};function se(e){var r=-1,n=e==null?0:e.length;for(this.clear();++rl))return!1;var u=s.get(e),c=s.get(r);if(u&&c)return u==r&&c==e;var f=-1,h=!0,g=2&n?new ra:void 0;for(s.set(e,r),s.set(r,e);++f-1&&e%1==0&&e-1&&e%1==0&&e<=9007199254740991},O={};O["[object Float32Array]"]=O["[object Float64Array]"]=O["[object Int8Array]"]=O["[object Int16Array]"]=O["[object Int32Array]"]=O["[object Uint8Array]"]=O["[object Uint8ClampedArray]"]=O["[object Uint16Array]"]=O["[object Uint32Array]"]=!0,O["[object Arguments]"]=O["[object Array]"]=O["[object ArrayBuffer]"]=O["[object Boolean]"]=O["[object DataView]"]=O["[object Date]"]=O["[object Error]"]=O["[object Function]"]=O["[object Map]"]=O["[object Number]"]=O["[object Object]"]=O["[object RegExp]"]=O["[object Set]"]=O["[object String]"]=O["[object WeakMap]"]=!1;var ya=function(e){return fe(e)&&on(e.length)&&!!O[de(e)]},wa=function(e){return function(r){return e(r)}},Tn=ye((function(e,r){var n=r&&!r.nodeType&&r,t=n&&e&&!e.nodeType&&e,a=t&&t.exports===n&&qn.process,s=(function(){try{var i=t&&t.require&&t.require("util").types;return i||a&&a.binding&&a.binding("util")}catch{}})();e.exports=s})),On=Tn&&Tn.isTypedArray,ir=On?wa(On):ya,_a=Object.prototype.hasOwnProperty,Na=function(e,r){var n=V(e),t=!n&&ar(e),a=!n&&!t&&Xe(e),s=!n&&!t&&!a&&ir(e),i=n||t||a||s,l=i?ga(e.length,String):[],o=l.length;for(var u in e)!_a.call(e,u)||i&&(u=="length"||a&&(u=="offset"||u=="parent")||s&&(u=="buffer"||u=="byteLength"||u=="byteOffset")||sr(u,o))||l.push(u);return l},ja=Object.prototype,Sa=function(e){var r=e&&e.constructor;return e===(typeof r=="function"&&r.prototype||ja)},ka=(function(e,r){return function(n){return e(r(n))}})(Object.keys,Object),Ca=Object.prototype.hasOwnProperty,Aa=function(e){if(!Sa(e))return ka(e);var r=[];for(var n in Object(e))Ca.call(e,n)&&n!="constructor"&&r.push(n);return r},Ea=function(e){return e!=null&&on(e.length)&&!nr(e)},ln=function(e){return Ea(e)?Na(e):Aa(e)},Mn=function(e){return ua(e,ln,ha)},Da=Object.prototype.hasOwnProperty,Ta=function(e,r,n,t,a,s){var i=1&n,l=Mn(e),o=l.length;if(o!=Mn(r).length&&!i)return!1;for(var u=o;u--;){var c=l[u];if(!(i?c in r:Da.call(r,c)))return!1}var f=s.get(e),h=s.get(r);if(f&&h)return f==r&&h==e;var g=!0;s.set(e,r),s.set(r,e);for(var m=i;++u1)return!1;if(e.length===1){var r=x(e,1)[0];return r.type==="text"&&!r.value}return!0}function fs(e){var r=e.changeKey,n=e.text,t=e.tokens,a=e.renderToken,s=ce(e,us),i=a?function(l,o){return a(l,Bn,o)}:Bn;return d.jsx("td",M(M({},s),{},{"data-change-key":r,children:t?cs(t)?" ":t.map(i):n||" "}))}var hr=j.memo(fs);function gr(e,r){return function(){var n=r==="old"?dn(e):hn(e);return n===-1?void 0:n}}function mr(e,r){return function(n){return e&&n?d.jsx("a",{href:r?"#"+r:void 0,children:n}):n}}function Te(e,r){return r?function(n){e(),r(n)}:e}function Gn(e,r,n,t){return j.useMemo((function(){var a=dr(e,(function(s){return function(i){return s&&s(r,i)}}));return a.onMouseEnter=Te(n,a.onMouseEnter),a.onMouseLeave=Te(t,a.onMouseLeave),a}),[e,n,t,r])}function xn(e,r,n,t,a,s,i,l,o){var u={change:r,side:t,inHoverState:l,renderDefault:gr(r,t),wrapInAnchor:mr(a,s)};return d.jsx("td",M(M({className:e},i),{},{"data-change-key":n,children:o(u)}))}function ds(e){var r,n,t,a=e.change,s=e.selected,i=e.tokens,l=e.className,o=e.generateLineClassName,u=e.gutterClassName,c=e.codeClassName,f=e.gutterEvents,h=e.codeEvents,g=e.hideGutter,m=e.gutterAnchor,v=e.generateAnchorID,b=e.renderToken,p=e.renderGutter,w=a.type,y=a.content,_=Q(a),N=(r=x(j.useState(!1),2),n=r[0],t=r[1],[n,j.useCallback((function(){return t(!0)}),[]),j.useCallback((function(){return t(!1)}),[])]),C=x(N,3),S=C[0],A=C[1],k=C[2],E=j.useMemo((function(){return{change:a}}),[a]),D=Gn(f,E,A,k),G=Gn(h,E,A,k),T=v(a),$=o({changes:[a],defaultGenerate:function(){return l}}),B=F("diff-gutter","diff-gutter-".concat(w),u,{"diff-gutter-selected":s}),X=F("diff-code","diff-code-".concat(w),c,{"diff-code-selected":s});return d.jsxs("tr",{id:T,className:F("diff-line",$),children:[!g&&xn(B,a,_,"old",m,T,D,S,p),!g&&xn(B,a,_,"new",m,T,D,S,p),d.jsx(hr,M({className:X,changeKey:_,text:y,tokens:i,renderToken:b},G))]})}var hs=j.memo(ds);function gs(e){var r=e.hideGutter,n=e.element;return d.jsx("tr",{className:"diff-widget",children:d.jsx("td",{colSpan:r?1:3,className:"diff-widget-content",children:n})})}var ms=["hideGutter","selectedChanges","tokens","lineClassName"],vs=["hunk","widgets","className"];function bs(e){var r=e.hunk,n=e.widgets,t=e.className,a=ce(e,vs),s=(function(i,l){return i.reduce((function(o,u){var c=Q(u);o.push(["change",c,u]);var f=l[c];return f&&o.push(["widget",c,f]),o}),[])})(r.changes,n);return d.jsx("tbody",{className:F("diff-hunk",t),children:s.map((function(i){return(function(l,o){var u=x(l,3),c=u[0],f=u[1],h=u[2],g=o.hideGutter,m=o.selectedChanges,v=o.tokens,b=o.lineClassName,p=ce(o,ms);if(c==="change"){var w=J(h)?"old":"new",y=J(h)?dn(h):hn(h),_=v?v[w][y-1]:null;return d.jsx(hs,M({className:b,change:h,hideGutter:g,selected:m.includes(f),tokens:_},p),"change".concat(f))}return c==="widget"?d.jsx(gs,{hideGutter:g,element:h},"widget".concat(f)):null})(i,a)}))})}var vr=0;function Ce(e,r,n,t){var a=j.useCallback((function(){return r(e)}),[e,r]),s=j.useCallback((function(){return r("")}),[r]);return j.useMemo((function(){var i=dr(t,(function(l){return function(o){return l&&l({side:e,change:n},o)}}));return i.onMouseEnter=Te(a,i.onMouseEnter),i.onMouseLeave=Te(s,i.onMouseLeave),i}),[n,t,a,e,s])}function Ue(e){var r=e.change,n=e.side,t=e.selected,a=e.tokens,s=e.gutterClassName,i=e.codeClassName,l=e.gutterEvents,o=e.codeEvents,u=e.anchorID,c=e.gutterAnchor,f=e.gutterAnchorTarget,h=e.hideGutter,g=e.hover,m=e.renderToken,v=e.renderGutter;if(!r){var b=F("diff-gutter","diff-gutter-omit",s),p=F("diff-code","diff-code-omit",i);return[!h&&d.jsx("td",{className:b},"gutter"),d.jsx("td",{className:p},"code")]}var w=r.type,y=r.content,_=Q(r),N=n===vr?"old":"new",C=M({id:u||void 0,className:F("diff-gutter","diff-gutter-".concat(w),He({"diff-gutter-selected":t},"diff-line-hover-"+N,g),s),children:v({change:r,side:N,inHoverState:g,renderDefault:gr(r,N),wrapInAnchor:mr(c,f)})},l),S=F("diff-code","diff-code-".concat(w),He({"diff-code-selected":t},"diff-line-hover-"+N,g),i);return[!h&&d.jsx("td",M(M({},C),{},{"data-change-key":_}),"gutter"),d.jsx(hr,M({className:S,changeKey:_,text:y,tokens:a,renderToken:m},o),"code")]}function ps(e){var r=e.className,n=e.oldChange,t=e.newChange,a=e.oldSelected,s=e.newSelected,i=e.oldTokens,l=e.newTokens,o=e.monotonous,u=e.gutterClassName,c=e.codeClassName,f=e.gutterEvents,h=e.codeEvents,g=e.hideGutter,m=e.generateAnchorID,v=e.generateLineClassName,b=e.gutterAnchor,p=e.renderToken,w=e.renderGutter,y=x(j.useState(""),2),_=y[0],N=y[1],C=Ce("old",N,n,f),S=Ce("new",N,t,f),A=Ce("old",N,n,h),k=Ce("new",N,t,h),E=n&&m(n),D=t&&m(t),G=v({changes:[n,t],defaultGenerate:function(){return r}}),T={monotonous:o,hideGutter:g,gutterClassName:u,codeClassName:c,gutterEvents:f,codeEvents:h,renderToken:p,renderGutter:w},$=M(M({},T),{},{change:n,side:vr,selected:a,tokens:i,gutterEvents:C,codeEvents:A,anchorID:E,gutterAnchor:b,gutterAnchorTarget:E,hover:_==="old"}),B=M(M({},T),{},{change:t,side:1,selected:s,tokens:l,gutterEvents:S,codeEvents:k,anchorID:n===t?null:D,gutterAnchor:b,gutterAnchorTarget:n===t?E:D,hover:_==="new"});if(o)return d.jsx("tr",{className:F("diff-line",G),children:Ue(n?$:B)});var X=(function(Z,L){return Z&&!L?"diff-line-old-only":!Z&&L?"diff-line-new-only":Z===L?"diff-line-normal":"diff-line-compare"})(n,t);return d.jsxs("tr",{className:F("diff-line",X,G),children:[Ue($),Ue(B)]})}var ys=j.memo(ps);function ws(e){var r=e.hideGutter,n=e.oldElement,t=e.newElement;return e.monotonous?d.jsx("tr",{className:"diff-widget",children:d.jsx("td",{colSpan:r?1:2,className:"diff-widget-content",children:n||t})}):n===t?d.jsx("tr",{className:"diff-widget",children:d.jsx("td",{colSpan:r?2:4,className:"diff-widget-content",children:n})}):d.jsxs("tr",{className:"diff-widget",children:[d.jsx("td",{colSpan:r?1:2,className:"diff-widget-content",children:n}),d.jsx("td",{colSpan:r?1:2,className:"diff-widget-content",children:t})]})}var _s=["selectedChanges","monotonous","hideGutter","tokens","lineClassName"],Ns=["hunk","widgets","className"];function Ae(e,r){return(e?Q(e):"00")+(r?Q(r):"00")}function js(e){var r=e.hunk,n=e.widgets,t=e.className,a=ce(e,Ns),s=(function(i,l){for(var o=function(p){if(!p)return null;var w=Q(p);return l[w]||null},u=[],c=0;ct.length?n:t,o=n.length>t.length?t:n,u=l.indexOf(o);if(u!=-1)return i=[new r.Diff(1,l.substring(0,u)),new r.Diff(0,o),new r.Diff(1,l.substring(u+o.length))],n.length>t.length&&(i[0][0]=i[2][0]=-1),i;if(o.length==1)return[new r.Diff(-1,n),new r.Diff(1,t)];var c=this.diff_halfMatch_(n,t);if(c){var f=c[0],h=c[1],g=c[2],m=c[3],v=c[4],b=this.diff_main(f,g,a,s),p=this.diff_main(h,m,a,s);return b.concat([new r.Diff(0,v)],p)}return a&&n.length>100&&t.length>100?this.diff_lineMode_(n,t,s):this.diff_bisect_(n,t,s)},r.prototype.diff_lineMode_=function(n,t,a){var s=this.diff_linesToChars_(n,t);n=s.chars1,t=s.chars2;var i=s.lineArray,l=this.diff_main(n,t,!1,a);this.diff_charsToLines_(l,i),this.diff_cleanupSemantic(l),l.push(new r.Diff(0,""));for(var o=0,u=0,c=0,f="",h="";o=1&&c>=1){l.splice(o-u-c,u+c),o=o-u-c;for(var g=this.diff_main(f,h,!1,a),m=g.length-1;m>=0;m--)l.splice(o,0,g[m]);o+=g.length}c=0,u=0,f="",h=""}o++}return l.pop(),l},r.prototype.diff_bisect_=function(n,t,a){for(var s=n.length,i=t.length,l=Math.ceil((s+i)/2),o=l,u=2*l,c=new Array(u),f=new Array(u),h=0;ha);y++){for(var _=-y+v;_<=y-b;_+=2){for(var N=o+_,C=(D=_==-y||_!=y&&c[N-1]s)b+=2;else if(C>i)v+=2;else if(m&&(k=o+g-_)>=0&&k=(A=s-f[k]))return this.diff_bisectSplit_(n,t,D,C,a)}for(var S=-y+p;S<=y-w;S+=2){for(var A,k=o+S,E=(A=S==-y||S!=y&&f[k-1]s)w+=2;else if(E>i)p+=2;else if(!m&&(N=o+g-S)>=0&&N=(A=s-A))return this.diff_bisectSplit_(n,t,D,C,a)}}}return[new r.Diff(-1,n),new r.Diff(1,t)]},r.prototype.diff_bisectSplit_=function(n,t,a,s,i){var l=n.substring(0,a),o=t.substring(0,s),u=n.substring(a),c=t.substring(s),f=this.diff_main(l,o,!1,i),h=this.diff_main(u,c,!1,i);return f.concat(h)},r.prototype.diff_linesToChars_=function(n,t){var a=[],s={};function i(u){for(var c="",f=0,h=-1,g=a.length;hs?n=n.substring(a-s):at.length?n:t,s=n.length>t.length?t:n;if(a.length<4||2*s.length=v.length?[w,y,_,N,A]:null}var o,u,c,f,h,g=l(a,s,Math.ceil(a.length/4)),m=l(a,s,Math.ceil(a.length/2));return g||m?(o=m?g&&g[4].length>m[4].length?g:m:g,n.length>t.length?(u=o[0],c=o[1],f=o[2],h=o[3]):(f=o[0],h=o[1],u=o[2],c=o[3]),[u,c,f,h,o[4]]):null},r.prototype.diff_cleanupSemantic=function(n){for(var t=!1,a=[],s=0,i=null,l=0,o=0,u=0,c=0,f=0;l0?a[s-1]:-1,o=0,u=0,c=0,f=0,i=null,t=!0)),l++;for(t&&this.diff_cleanupMerge(n),this.diff_cleanupSemanticLossless(n),l=1;l=v?(m>=h.length/2||m>=g.length/2)&&(n.splice(l,0,new r.Diff(0,g.substring(0,m))),n[l-1][1]=h.substring(0,h.length-m),n[l+1][1]=g.substring(m),l++):(v>=h.length/2||v>=g.length/2)&&(n.splice(l,0,new r.Diff(0,h.substring(0,v))),n[l-1][0]=1,n[l-1][1]=g.substring(0,g.length-v),n[l+1][0]=-1,n[l+1][1]=h.substring(v),l++),l++}l++}},r.prototype.diff_cleanupSemanticLossless=function(n){function t(v,b){if(!v||!b)return 6;var p=v.charAt(v.length-1),w=b.charAt(0),y=p.match(r.nonAlphaNumericRegex_),_=w.match(r.nonAlphaNumericRegex_),N=y&&p.match(r.whitespaceRegex_),C=_&&w.match(r.whitespaceRegex_),S=N&&p.match(r.linebreakRegex_),A=C&&w.match(r.linebreakRegex_),k=S&&v.match(r.blanklineEndRegex_),E=A&&b.match(r.blanklineStartRegex_);return k||E?5:S||A?4:y&&!N&&C?3:N||C?2:y||_?1:0}for(var a=1;a=g&&(g=m,c=s,f=i,h=l)}n[a-1][1]!=c&&(c?n[a-1][1]=c:(n.splice(a-1,1),a--),n[a][1]=f,h?n[a+1][1]=h:(n.splice(a+1,1),a--))}a++}},r.nonAlphaNumericRegex_=/[^a-zA-Z0-9]/,r.whitespaceRegex_=/\s/,r.linebreakRegex_=/[\r\n]/,r.blanklineEndRegex_=/\n\r?\n$/,r.blanklineStartRegex_=/^\r?\n\r?\n/,r.prototype.diff_cleanupEfficiency=function(n){for(var t=!1,a=[],s=0,i=null,l=0,o=!1,u=!1,c=!1,f=!1;l0?a[s-1]:-1,c=f=!1),t=!0)),l++;t&&this.diff_cleanupMerge(n)},r.prototype.diff_cleanupMerge=function(n){n.push(new r.Diff(0,""));for(var t,a=0,s=0,i=0,l="",o="";a1?(s!==0&&i!==0&&((t=this.diff_commonPrefix(o,l))!==0&&(a-s-i>0&&n[a-s-i-1][0]==0?n[a-s-i-1][1]+=o.substring(0,t):(n.splice(0,0,new r.Diff(0,o.substring(0,t))),a++),o=o.substring(t),l=l.substring(t)),(t=this.diff_commonSuffix(o,l))!==0&&(n[a][1]=o.substring(o.length-t)+n[a][1],o=o.substring(0,o.length-t),l=l.substring(0,l.length-t))),a-=s+i,n.splice(a,s+i),l.length&&(n.splice(a,0,new r.Diff(-1,l)),a++),o.length&&(n.splice(a,0,new r.Diff(1,o)),a++),a++):a!==0&&n[a-1][0]==0?(n[a-1][1]+=n[a][1],n.splice(a,1)):a++,i=0,s=0,l="",o=""}n[n.length-1][1]===""&&n.pop();var u=!1;for(a=1;at));a++)l=s,o=i;return n.length!=a&&n[a][0]===-1?o:o+(t-l)},r.prototype.diff_prettyHtml=function(n){for(var t=[],a=/&/g,s=//g,l=/\n/g,o=0;o");switch(u){case 1:t[o]=''+c+"";break;case-1:t[o]=''+c+"";break;case 0:t[o]=""+c+""}}return t.join("")},r.prototype.diff_text1=function(n){for(var t=[],a=0;athis.Match_MaxBits)throw new Error("Pattern too long for this browser.");var s=this.match_alphabet_(t),i=this;function l(C,S){var A=C/t.length,k=Math.abs(a-S);return i.Match_Distance?A+k/i.Match_Distance:k?1:A}var o=this.Match_Threshold,u=n.indexOf(t,a);u!=-1&&(o=Math.min(l(0,u),o),(u=n.lastIndexOf(t,a+t.length))!=-1&&(o=Math.min(l(0,u),o)));var c,f,h=1<=b;y--){var _=s[n.charAt(y-1)];if(w[y]=v===0?(w[y+1]<<1|1)&_:(w[y+1]<<1|1)&_|(g[y+1]|g[y])<<1|1|g[y+1],w[y]&h){var N=l(v,y-1);if(N<=o){if(o=N,!((u=y-1)>a))break;b=Math.max(1,2*a-u)}}}if(l(v+1,a)>o)break;g=w}return u},r.prototype.match_alphabet_=function(n){for(var t={},a=0;a2&&(this.diff_cleanupSemantic(i),this.diff_cleanupEfficiency(i));else if(n&&typeof n=="object"&&t===void 0&&a===void 0)i=n,s=this.diff_text1(i);else if(typeof n=="string"&&t&&typeof t=="object"&&a===void 0)s=n,i=t;else{if(typeof n!="string"||typeof t!="string"||!a||typeof a!="object")throw new Error("Unknown call format to patch_make.");s=n,i=a}if(i.length===0)return[];for(var l=[],o=new r.patch_obj,u=0,c=0,f=0,h=s,g=s,m=0;m=2*this.Patch_Margin&&u&&(this.patch_addContext_(o,h),l.push(o),o=new r.patch_obj,u=0,h=g,c=f)}v!==1&&(c+=b.length),v!==-1&&(f+=b.length)}return u&&(this.patch_addContext_(o,h),l.push(o)),l},r.prototype.patch_deepCopy=function(n){for(var t=[],a=0;athis.Match_MaxBits?(o=this.match_main(t,f.substring(0,this.Match_MaxBits),c))!=-1&&((h=this.match_main(t,f.substring(f.length-this.Match_MaxBits),c+f.length-this.Match_MaxBits))==-1||o>=h)&&(o=-1):o=this.match_main(t,f,c),o==-1)i[l]=!1,s-=n[l].length2-n[l].length1;else if(i[l]=!0,s=o-c,f==(u=h==-1?t.substring(o,o+f.length):t.substring(o,h+this.Match_MaxBits)))t=t.substring(0,o)+this.diff_text2(n[l].diffs)+t.substring(o+f.length);else{var g=this.diff_main(f,u,!1);if(f.length>this.Match_MaxBits&&this.diff_levenshtein(g)/f.length>this.Patch_DeleteThreshold)i[l]=!1;else{this.diff_cleanupSemanticLossless(g);for(var m,v=0,b=0;bl[0][1].length){var o=t-l[0][1].length;l[0][1]=a.substring(l[0][1].length)+l[0][1],i.start1-=o,i.start2-=o,i.length1+=o,i.length2+=o}return(l=(i=n[n.length-1]).diffs).length==0||l[l.length-1][0]!=0?(l.push(new r.Diff(0,a)),i.length1+=t,i.length2+=t):t>l[l.length-1][1].length&&(o=t-l[l.length-1][1].length,l[l.length-1][1]+=a.substring(0,o),i.length1+=o,i.length2+=o),a},r.prototype.patch_splitMax=function(n){for(var t=this.Match_MaxBits,a=0;a2*t?(u.length1+=h.length,i+=h.length,c=!1,u.diffs.push(new r.Diff(f,h)),s.diffs.shift()):(h=h.substring(0,t-u.length1-this.Patch_Margin),u.length1+=h.length,i+=h.length,f===0?(u.length2+=h.length,l+=h.length):c=!1,u.diffs.push(new r.Diff(f,h)),h==s.diffs[0][1]?s.diffs.shift():s.diffs[0][1]=s.diffs[0][1].substring(h.length))}o=(o=this.diff_text2(u.diffs)).substring(o.length-this.Patch_Margin);var g=this.diff_text1(s.diffs).substring(0,this.Patch_Margin);g!==""&&(u.length1+=g.length,u.length2+=g.length,u.diffs.length!==0&&u.diffs[u.diffs.length-1][0]===0?u.diffs[u.diffs.length-1][1]+=g:u.diffs.push(new r.Diff(0,g))),c||n.splice(++a,0,u)}}},r.prototype.patch_toText=function(n){for(var t=[],a=0;aMs(e.patch),[e.patch]);return d.jsxs("section",{children:[d.jsxs("div",{className:"flex items-baseline justify-between gap-4 flex-wrap",children:[d.jsx("h3",{className:"text-body font-semibold text-fg",children:"Local Changes"}),d.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:[e.changedFiles.length," changed file",e.changedFiles.length===1?"":"s"]})]}),e.rootPath.kind==="known"&&d.jsx("p",{className:"mt-1 text-label text-fg-faint break-all",children:e.rootPath.path}),d.jsx("p",{className:"mt-2 text-label uppercase tracking-wider text-fg-muted",children:Rs(e.comparison)}),r.length===0?d.jsx("p",{className:"mt-5 text-body text-fg-muted italic",children:"No renderable patch in this work tree."}):d.jsx("div",{className:"formula-run-diff-view mt-5 space-y-3",children:r.map(n=>d.jsx(Os,{file:n},`${n.oldRevision}:${n.newRevision}:${pr(n)}`))}),e.truncated&&d.jsx("p",{className:"mt-3 text-label uppercase tracking-wider text-fg-faint",children:"Diff truncated at the backend output cap."})]})}function Os({file:e}){const r=Ps(e.hunks);return d.jsxs("details",{className:"border-y border-rule py-2",open:!0,children:[d.jsxs("summary",{className:"cursor-pointer list-none text-label uppercase tracking-wider text-fg-muted",children:[d.jsx("span",{className:"font-medium normal-case tracking-normal text-body text-fg",children:pr(e)}),d.jsxs("span",{className:"ml-3 tnum text-fg-faint",children:["+",r.additions," -",r.deletions]})]}),e.hunks.length===0||e.isBinary?d.jsx("p",{className:"mt-3 text-body text-fg-muted italic",children:"No textual hunks."}):d.jsx("div",{className:"mt-3 overflow-auto",children:d.jsx(Es,{viewType:"unified",diffType:e.type,hunks:e.hunks,renderGutter:Is,children:n=>n.map(t=>d.jsx(br,{hunk:t},$s(t)))})})]})}function Ms(e){if(e.trim().length===0)return[];try{return ct(e,{nearbySequences:"zip"})}catch{return[]}}function Is({change:e,side:r,renderDefault:n}){return e.type==="insert"&&r==="old"?d.jsx("span",{className:"diff-gutter-sign","aria-hidden":!0,children:"+"}):e.type==="delete"&&r==="new"?d.jsx("span",{className:"diff-gutter-sign","aria-hidden":!0,children:"-"}):n()}function Rs(e){return e.kind==="upstream"?`Compared with ${e.ref} at ${e.mergeBase.slice(0,12)}.`:e.kind==="head"&&e.reason==="no_upstream"?"No upstream branch is configured; showing changes relative to HEAD plus untracked files.":e.kind==="head"?"Upstream comparison failed; showing changes relative to HEAD plus untracked files.":"Comparison unavailable."}function pr(e){const r=Un(e.oldPath),n=Un(e.newPath);return e.type==="delete"?r:e.type==="rename"&&r!==n?`${r} -> ${n}`:n||r}function Un(e){return e.replace(/^[ab]\//,"")}function Ps(e){let r=0,n=0;for(const t of e)for(const a of t.changes)a.type==="insert"&&(r+=1),a.type==="delete"&&(n+=1);return{additions:r,deletions:n}}function $s(e){return`${e.oldStart}:${e.newStart}:${e.content}`}function Fs({node:e,visible:r}){const n=j.useMemo(()=>e?.executionInstances.sort(wr)??[],[e]),t=j.useMemo(()=>Ls(e?.visibleExecutionInstanceId,n),[e?.visibleExecutionInstanceId,n]),[a,s]=j.useState(null);if(j.useEffect(()=>{s(t?z(t):null)},[e?.id,t]),!e)return d.jsx("p",{className:"text-body text-fg-muted italic",children:"Select a node to inspect its session."});if(n.length===0)return d.jsx("p",{className:"text-body text-fg-muted italic",children:zn(e)});const i=n.find(c=>z(c)===a)??t??n[0],l=i?pe(i):"base",o=Us(n),u=n.filter(c=>pe(c)===l);return i?d.jsxs("section",{children:[d.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[d.jsx("h3",{className:"text-body font-semibold text-fg",children:e.title}),(e.historicalOnly||i?.historical)&&d.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint",children:e.historicalOnly?"historical-only":"historical"})]}),o.length>1&&d.jsxs("div",{className:"mt-3 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-label",role:"radiogroup","aria-label":"Iterations",children:[d.jsx("span",{className:"uppercase tracking-wider text-fg-faint",children:"Iterations"}),o.map(c=>{const f=c.instances.at(-1);if(!f)return null;const h=c.iteration==="base"?"Base":`Iteration ${c.iteration}`,g=c.iteration===l;return d.jsxs("span",{className:"flex items-baseline gap-1",children:[d.jsx("span",{"aria-hidden":!0,className:"text-fg-faint",children:"·"}),d.jsx("button",{type:"button",role:"radio","aria-checked":g,className:`focus-mark rounded-sm px-0.5 uppercase tracking-wider ${g?"text-fg font-semibold underline decoration-fg underline-offset-4":"text-fg-muted hover:text-fg"}`,onClick:()=>s(z(f)),children:h})]},h)})]}),u.length>1&&d.jsxs("div",{className:"mt-2 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-label",role:"radiogroup","aria-label":"Attempts",children:[d.jsx("span",{className:"uppercase tracking-wider text-fg-faint",children:"Attempts"}),u.map(c=>d.jsxs("span",{className:"flex items-baseline gap-1",children:[d.jsx("span",{"aria-hidden":!0,className:"text-fg-faint",children:"·"}),d.jsxs("button",{type:"button",role:"radio","aria-checked":z(c)===z(i),className:`focus-mark rounded-sm px-0.5 uppercase tracking-wider ${z(c)===z(i)?"text-fg font-semibold underline decoration-fg underline-offset-4":"text-fg-muted hover:text-fg"}`,onClick:()=>s(z(c)),children:["Attempt ",qe(c)]})]},z(c)))]}),d.jsxs("dl",{className:"mt-4 grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-1 text-label",children:[d.jsx("dt",{className:"uppercase tracking-wider text-fg-faint",children:"Execution instance"}),d.jsx("dd",{className:"break-all text-fg-muted tnum",children:i.id}),d.jsx("dt",{className:"uppercase tracking-wider text-fg-faint",children:"Bead"}),d.jsx("dd",{className:"break-all text-fg-muted tnum",children:i.beadId})]}),d.jsx(Bs,{instance:i,visible:r})]}):d.jsx("p",{className:"text-body text-fg-muted italic",children:zn(e)})}function Bs({instance:e,visible:r}){const n=e.session.kind==="attached"?e.session:null,t=n?.link.sessionId??null,a=r&&!!n?.streamable,s=zr(t,a);if(n===null)return d.jsx("p",{className:"mt-5 text-body text-fg-muted italic",children:xs(e)});const i=Gs(s.stream),l=s.status==="loading",o=s.status==="ready"?s.result:null,u=s.status==="failed"?s.error:null,c=s.status==="ready"&&s.stream.status==="degraded"?s.stream.error:null;return d.jsxs("div",{className:"mt-5 space-y-4",children:[n?.streamable&&d.jsx("div",{className:"flex justify-end",children:d.jsx(Er,{tone:i.tone,label:i.label,title:`Session stream: ${s.stream.status}`,className:"text-label uppercase tracking-wider"})}),c!==null&&d.jsx("p",{className:"text-accent",role:"alert",children:c}),d.jsx(Kr,{loading:l,error:u,result:o})]})}function Gs(e){switch(e.status){case"open":return{tone:"ok",label:"live"};case"connecting":return{tone:"warn",label:"connecting"};case"closed":return{tone:"stuck",label:"offline"};case"degraded":return{tone:"warn",label:"degraded"};case"idle":return{tone:"neutral",label:"snapshot"}}}function zn(e){const r=e.executionInstances.filter(t=>t.session.kind==="none");return r.some(t=>t.currentIteration&&t.session.kind==="none"&&t.session.reason==="session_unresolved"&&yr(t.status))?"Session unresolved for the current running node.":r.some(t=>t.session.kind==="none"&&t.session.reason==="session_unresolved")?"Session unresolved for this node.":"This node has not started a session yet."}function xs(e){return e.session.kind==="attached"?"":e.currentIteration&&e.session.reason==="session_unresolved"&&yr(e.status)?"Session unresolved for the current running node.":e.session.reason==="session_unresolved"?"Session unresolved for this node.":"This node has not started a session yet."}function yr(e){return e==="active"||e==="running"}function Ls(e,r){return(e?r.find(t=>z(t)===e):void 0)??r.at(-1)}function Us(e){const r=new Map;for(const n of e){const t=pe(n);r.set(t,[...r.get(t)??[],n])}return[...r.entries()].map(([n,t])=>({iteration:n,instances:t.sort(wr)})).sort((n,t)=>Oe(n.iteration)-Oe(t.iteration))}function wr(e,r){return Oe(pe(e))-Oe(pe(r))||qe(e)-qe(r)||e.id.localeCompare(r.id)}function z(e){return e.id}function pe(e){return e.iteration.kind==="loop"?e.iteration.value:"base"}function Oe(e){return e==="base"?0:e}function qe(e){return e.attempt.kind==="attempt"?e.attempt.value:1}function zs({tab:e,diff:r,selectedNode:n}){return e==="session"?d.jsx(Fs,{node:n,visible:!0}):d.jsx(Ks,{diff:r})}function Ks({diff:e}){switch(e.kind){case"idle":return d.jsx("p",{className:"text-body text-fg-muted italic",children:"Local changes are not loaded for this run."});case"loading":return d.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading local changes."});case"failed":return d.jsx("p",{className:"text-body text-accent",role:"alert",children:e.error});case"ready":return d.jsxs(d.Fragment,{children:[e.refreshState.kind==="failed"&&d.jsx("p",{className:"mb-4 text-body text-accent",role:"alert",children:e.refreshState.error}),e.refreshState.kind==="refreshing"&&d.jsx("p",{className:"mb-4 text-label uppercase tracking-wider text-fg-faint",role:"status",children:"Refreshing local changes"}),d.jsx(Ds,{diff:e.diff})]})}}function Hs({diff:e,selectedNode:r,activeTab:n,onActiveTabChange:t}){const[a,s]=j.useState("diff"),i=n!==void 0&&t!==void 0,l=i?n:a,o=c=>{i?t(c):s(c)},u=`run-evidence-tab-${l}`;return d.jsxs("section",{"aria-label":"Run evidence",children:[d.jsxs("div",{className:"flex items-baseline gap-2 text-label",role:"tablist","aria-label":"Run evidence views",children:[d.jsx(Kn,{id:"run-evidence-tab-diff",controls:"run-evidence-panel",active:l==="diff",onClick:()=>o("diff"),children:"Diff"}),d.jsx("span",{"aria-hidden":!0,className:"text-fg-faint",children:"·"}),d.jsx(Kn,{id:"run-evidence-tab-session",controls:"run-evidence-panel",active:l==="session",onClick:()=>o("session"),children:"Session"})]}),d.jsx("div",{id:"run-evidence-panel",role:"tabpanel","aria-labelledby":u,className:"pt-5",children:d.jsx(zs,{tab:l,diff:e,selectedNode:r})})]})}function Kn({id:e,controls:r,active:n,disabled:t=!1,onClick:a,children:s}){return d.jsx("button",{id:e,type:"button",role:"tab","aria-selected":n,"aria-controls":r,"aria-disabled":t||void 0,disabled:t,className:`focus-mark rounded-sm px-0.5 uppercase tracking-wider ${t?"cursor-not-allowed text-fg-faint":n?"text-fg font-semibold underline decoration-fg underline-offset-4":"text-fg-muted hover:text-fg"}`,onClick:a,children:s})}function Vs(e,r){const n=e.runIds.size===0||e.runIds.has(r.runId),t=e.rootBeadIds.size===0||e.rootBeadIds.has(r.rootBeadId);return n&&t}function Ws(e){const r={runIds:new Set,rootBeadIds:new Set};return Y(e,r),Y(P(e.run),r),Y(P(e.payload),r),Y(P(P(e.payload)?.run),r),Y(P(e.bead),r),Y(P(P(e.payload)?.bead),r),Y(P(e.root),r),Y(P(P(e.payload)?.root),r),en(P(e.metadata),r),en(P(P(e.payload)?.metadata),r),r}function Y(e,r){e&&(H(r.runIds,e.run_id),H(r.runIds,e.workflow_id),H(r.rootBeadIds,e.root_bead_id),en(P(e.metadata),r))}function en(e,r){e&&(H(r.runIds,e["gc.run_id"]),H(r.runIds,e["gc.workflow_id"]),H(r.runIds,e.run_id),H(r.runIds,e.workflow_id),H(r.rootBeadIds,e["gc.root_bead_id"]),H(r.rootBeadIds,e.root_bead_id))}function H(e,r){if(typeof r!="string")return;const n=r.trim();n&&e.add(n)}function P(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)?e:void 0}function Xs(e,r,n){const[t,a]=j.useState({nodeId:null,routeKey:"",source:"route"});j.useEffect(()=>{if(!e)return;const u=Zs(e,r);a(c=>c.routeKey===n&&(c.source==="user"||c.nodeId===u)?c:{nodeId:u,routeKey:n,source:"route"})},[e,n,r]);const s=j.useCallback(()=>{a(u=>({nodeId:null,routeKey:u.routeKey,source:"user"}))},[]);j.useEffect(()=>{const u=c=>{c.key==="Escape"&&s()};return window.addEventListener("keydown",u),()=>window.removeEventListener("keydown",u)},[s]);const i=j.useCallback(u=>{a(c=>({nodeId:c.nodeId===u?null:u,routeKey:n,source:"user"}))},[n]),l=t.nodeId,o=j.useMemo(()=>e?.nodes.find(u=>u.id===l)??null,[e,l]);return{selectedNodeId:l,selectedNode:o,toggleNode:i,clearSelection:s}}function Zs(e,r){return r&&e.nodes.some(n=>n.id===r)?r:null}const Ys=[600,1200,2400];async function Js(e){for(let r=0;;r+=1)try{return await Me.runDetail(e)}catch(n){const t=Ys[r];if(t!==void 0&&Qs(n)){await qs(t);continue}throw n}}function Qs(e){return e instanceof Ke?e.status>=500:e instanceof TypeError}function qs(e){return new Promise(r=>setTimeout(r,e))}function ei(e,r,n,t,a){const[s,i]=j.useState("unavailable"),l=j.useRef(n);l.current=n;const o=j.useRef(!1),u=_r(e,t,a);return j.useEffect(()=>{if(o.current=!1,!e||!r||typeof EventSource>"u"){i("unavailable");return}let c=!1;i("connecting");const f=new EventSource(Me.runDetailStreamUrl(e),{withCredentials:!0});f.onopen=()=>{c||i("open")};const h=g=>{if(c)return;const m=ni(g.data,e,o);m!==null&&(Dr(u,{kind:"loaded",detail:m}),l.current?.(m,u),i("open"))};return f.addEventListener("detail",h),f.onerror=()=>{c||i(f.readyState===EventSource.CLOSED?"closed":"connecting")},()=>{c=!0,f.close()}},[e,r,u]),s}function ni(e,r,n){let t;try{t=JSON.parse(e)}catch(a){return Hn(r,n,a),null}try{return Tr(t,Me.runDetailStreamUrl(r))}catch(a){return Hn(r,n,a),null}}function Hn(e,r,n){r.current||(r.current=!0,nn({component:"formula-run-detail-stream",operation:"parse stream frame",message:`${e}: ${rn(n)}`}))}function ri(e,r,n){const t=_r(e,r,n),{data:a,loading:s,error:i,refresh:l}=Xn(t,()=>ti(e),{onError:p=>{e!==void 0&&ii("load detail",e,p)}}),[o,u]=j.useState(null),c=j.useCallback((p,w)=>u({key:w,detail:p}),[]),f=e!==void 0&&a?.kind!=="unsupported"&&a?.kind!=="not_found",h=ei(e,f,c,r,n),g=o?.key===t?o.detail:null,m=h==="open"||h==="connecting",v=j.useCallback(async()=>{u(null),await l()},[l]);if(e===void 0)return{kind:"idle",refresh:ai,streamActive:m};const b=g??(a?.kind==="loaded"?a.detail:null);return b!==null?{kind:"ready",detail:b,refresh:v,refreshState:si(s,i),streamActive:m}:a?.kind==="unsupported"?{kind:"unsupported",refresh:v,streamActive:m}:a?.kind==="not_found"?{kind:"not_found",refresh:v,streamActive:m}:i!==null?{kind:"failed",error:i,refresh:v,streamActive:m}:{kind:"loading",refresh:v,streamActive:m}}async function ti(e){if(!e)return{kind:"unrequested"};try{return{kind:"loaded",detail:await Js(e)}}catch(r){if(r instanceof Ke&&r.status===422&&r.reason==="not_run_view")return{kind:"unsupported"};if(r instanceof Ke&&r.status===404)return{kind:"not_found"};throw r}}async function ai(){}function si(e,r){return r!==null?{kind:"failed",error:r}:e?{kind:"refreshing"}:{kind:"idle"}}function ii(e,r,n){nn({component:"formula-run-detail",operation:e,message:`${r}: ${rn(n)}`})}function _r(e,r,n){return["formula-run",e??"missing",r??"default",n??"default"].map(encodeURIComponent).join(":")}function oi(e,r,n,t){const a=ci(e,r,n,t),{data:s,loading:i,error:l,refresh:o,cheapRefresh:u}=Xn(a,()=>ze(e,r,n,t),{refreshFetcher:()=>ze(e,r,n,t,!0),sseRefreshFetcher:()=>ze(e,r,n,t,!1),onError:c=>{e!==void 0&&ui("load diff",e,c)}});return e===void 0||r===void 0?{kind:"idle",refresh:Vn,cheapRefresh:Vn}:s?.kind==="loaded"?{kind:"ready",diff:s.diff,refresh:o,cheapRefresh:u,refreshState:li(i,l)}:l!==null?{kind:"failed",error:l,refresh:o,cheapRefresh:u}:{kind:"loading",refresh:o,cheapRefresh:u}}async function ze(e,r,n,t,a){if(!e||r===void 0)return{kind:"unrequested"};const s={};return n!==void 0&&(s.scopeKind=n),t!==void 0&&(s.scopeRef=t),a&&(s.refresh=!0),{kind:"loaded",diff:await Me.runDiff(e,{executionPath:r},s)}}async function Vn(){}function li(e,r){return r!==null?{kind:"failed",error:r}:e?{kind:"refreshing"}:{kind:"idle"}}function ui(e,r,n){nn({component:"formula-run-detail",operation:e,message:`${r}: ${rn(n)}`})}function ci(e,r,n,t){return["formula-run-diff",e??"missing",fi(r),n??"default",t??"default"].join(":")}function fi(e){return e===void 0?"path:missing":e.kind==="known"?`path:${e.path}`:`path:${e.reason}`}const di=[yn.bead,yn.session],hi=[];function Fi(){const{runId:e}=Or(),[r]=Mr(),n=Ci(r),t=n.ok?n.scope:void 0,a=n.ok?null:n.error,s=r.get("node"),i=[e??"",t?.scopeKind??"",t?.scopeRef??"",s??""].join("\0"),l=ri(a?void 0:e,t?.scopeKind,t?.scopeRef),o=l.kind==="ready"?l:null,u=o?.detail??null,c=l.kind==="unsupported",f=l.kind==="not_found",h=oi(a||u===null?void 0:e,u?.executionPath,t?.scopeKind,t?.scopeRef),g=l.kind==="loading",m=o!==null&&o.refreshState.kind==="refreshing"||h.kind==="ready"&&h.refreshState.kind==="refreshing",v=u!==null&&h.kind==="loading",b=g||m||v,p=l.kind==="failed"?l.error:o!==null&&o.refreshState.kind==="failed"?o.refreshState.error:null,[w,y]=j.useState("diff"),_=w==="diff",N=l.streamActive;Ir(a?hi:di,()=>{gi(N,_,l.refresh,h.cheapRefresh)},{matches:I=>{if(u===null)return!1;const U=Ws(I);return u.progress.terminal&&vi(U)?!1:Vs(U,{runId:u.runId,rootBeadId:u.rootBeadId})}});const C=j.useRef(h.cheapRefresh);C.current=h.cheapRefresh;const S=j.useRef(_);j.useEffect(()=>{const I=S.current;S.current=_,_&&!I&&C.current()},[_]);const A=j.useCallback(I=>y(I),[]),k=a??p,{selectedNodeId:E,selectedNode:D,toggleNode:G}=Xs(u,s,i),T=xr(u?.rootBeadId??null),[$,B]=j.useState(null),X=Rr(),Z=Br(),[L]=j.useState(()=>Pr(`runs:summary:${Z??"no-city"}`)),q=j.useMemo(()=>{if(!e)return null;const I=L&&L.status!=="error"?L.data:null;return I==null?null:[...I.lanes,...I.blockedLanes].find(U=>U.id===e)??null},[L,e]),Be=u?`${u.progress.visibleNodeCount} nodes. ${Ai(u.progress)}. Local changes are shown for the run execution folder.`:g&&!a||c||f?void 0:"Formula run unavailable.";return d.jsxs("section",{children:[d.jsx(Gr,{title:u?.title??"Formula Run",synopsis:Be,meta:d.jsxs(d.Fragment,{children:[d.jsx($r,{to:"/runs",className:"focus-mark text-label uppercase tracking-wider text-fg-muted hover:text-fg",children:"Runs"}),k&&u&&d.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:k}),u&&d.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:yi(u)}),d.jsx(Fr,{size:"sm",onClick:()=>{Nr(l.refresh,h.refresh)},disabled:b||!!a,children:m?"Refreshing":"Refresh"})]})}),b&&!a&&!u?q?d.jsxs(d.Fragment,{children:[d.jsx(wn,{stages:q.stages,label:q.title}),d.jsx("p",{className:"text-body text-fg-muted italic mt-8",children:"Loading run detail."})]}):d.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading formula run."}):c?d.jsx("p",{className:"text-body text-fg-muted",role:"status",children:"Detailed step view isn’t available for this run (v1/wisp runs are list-only) — this run appears in the run list only."}):f?d.jsx("p",{className:"text-body text-fg-muted",role:"status",children:"This run’s detail snapshot was not found. It may be a v1/wisp run, a completed run whose snapshot wasn’t retained, or no longer available."}):k&&!u?d.jsx("p",{className:"text-body text-accent",role:"alert",children:k}):o?d.jsxs(d.Fragment,{children:[d.jsx(bi,{detail:o.detail}),d.jsx(wn,{stages:o.detail.stages,label:o.detail.title}),d.jsx(_i,{detail:o.detail}),d.jsxs("div",{className:"mt-8 grid gap-10 lg:grid-cols-[minmax(0,0.95fr)_minmax(22rem,1.05fr)]",children:[d.jsx(Qr,{detail:o.detail,selectedNodeId:E,onToggleNode:G}),d.jsx(Hs,{diff:h,selectedNode:D,activeTab:w,onActiveTabChange:A})]}),d.jsx(Lr,{view:T.view,loading:T.loading,error:T.error,now:X,onOpenBead:B}),d.jsx(Ur,{open:$!==null,onClose:()=>B(null),beadId:$,onOpenBead:B})]}):null]})}async function Nr(e,r){await Promise.all([e(),r()])}function gi(e,r,n,t){const a=r?t:mi;return e?a():Nr(n,a)}async function mi(){}function vi(e){return e.runIds.size===0&&e.rootBeadIds.size===0}function bi({detail:e}){const r=wi(e.formulaDetail);return d.jsxs("dl",{className:"grid gap-x-8 gap-y-3 sm:grid-cols-2 lg:grid-cols-4",children:[d.jsx(pi,{formula:e.formula}),r!==null&&d.jsx(ue,{label:"Formula Detail",value:r}),d.jsx(ue,{label:"Root",value:e.rootBeadId}),d.jsx(ue,{label:"Scope",value:`${e.scopeKind}:${e.scopeRef}`}),d.jsx(ue,{label:"Store",value:e.resolvedRootStore||e.rootStoreRef||"unknown"})]})}function ue({label:e,value:r}){return d.jsxs("div",{children:[d.jsx("dt",{className:"text-label uppercase tracking-wider text-fg-faint",children:e}),d.jsx("dd",{className:"text-body text-fg break-all tnum",children:r})]})}const Wn="name inferred from bead title — supervisor did not set gc.formula on this graph.v2 root";function pi({formula:e}){if(e.kind!=="known")return d.jsx(ue,{label:"Formula",value:"metadata missing"});switch(e.source){case"metadata":return d.jsx(ue,{label:"Formula",value:e.name});case"title_fallback":return d.jsxs("div",{children:[d.jsx("dt",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Formula"}),d.jsxs("dd",{className:"text-body text-warn break-all tnum",title:Wn,"aria-label":`${e.name} (${Wn})`,children:[e.name,d.jsx("span",{className:"ml-2 text-label uppercase tracking-wider text-warn",children:"inferred from bead title"})]})]});default:return e.source}}function yi(e){return e.snapshotEventSeq.kind==="known"?`v${e.snapshotVersion} · seq ${e.snapshotEventSeq.seq}`:`v${e.snapshotVersion}`}function wi(e){return e.kind==="available"?`available for ${e.target}`:e.reason==="missing_formula_metadata"?null:e.reason==="missing_run_target"?`missing run target for ${e.name}`:`${e.failure} for ${e.target}`}function _i({detail:e}){if(e.completeness.kind!=="partial")return null;const r=Ni(e.completeness.reasons);return r.length===0?null:d.jsxs("p",{className:"mt-5 text-label uppercase tracking-wider text-warn",role:"status",children:["Partial run data: ",Si(r),"."]})}function Ni(e){return e.filter(r=>!ji(r))}function ji(e){switch(e){case"formula_detail_missing_formula_metadata":case"formula_detail_missing_run_target":case"formula_detail_fetch_failed":return!0;case"supervisor_snapshot_partial":case"runtime_bead_read_failed":case"session_list_failed":return!1}}function Si(e){return e.map(ki).join(", ")}function ki(e){switch(e){case"supervisor_snapshot_partial":return"supervisor snapshot is partial";case"runtime_bead_read_failed":return"runtime bead refresh failed";case"session_list_failed":return"session list failed";case"formula_detail_missing_formula_metadata":return"formula metadata is missing";case"formula_detail_missing_run_target":return"formula run target is missing";case"formula_detail_fetch_failed":return"formula detail fetch failed"}}function Ci(e){const r=e.getAll("scope_kind"),n=e.getAll("scope_ref");if(r.length>1||n.length>1)return{ok:!1,error:"Invalid run scope query."};const t=r[0],a=n[0];return t===void 0&&a===void 0?{ok:!0}:t===void 0||a===void 0?{ok:!1,error:"Invalid run scope query."}:t!=="city"&&t!=="rig"?{ok:!1,error:"Invalid run scope query."}:Hr.test(a)?{ok:!0,scope:{scopeKind:t,scopeRef:a}}:{ok:!1,error:"Invalid run scope query."}}function Ai(e){const r=[ne(e,["active","running"],"running"),ne(e,["completed","done"],"done"),ne(e,"ready","ready"),ne(e,"blocked","blocked"),ne(e,"failed","failed"),ne(e,"skipped","skipped"),ne(e,"pending","pending")].filter(n=>n!==null);return r.length>0?r.join(", "):"No node status yet"}function ne(e,r,n){const a=(typeof r=="string"?[r]:r).reduce((s,i)=>s+(e.statusCounts[i]??0),0);return a>0?`${a} ${n}`:null}export{Fi as FormulaRunDetailPage,gi as runDetailNudgeRefresh}; diff --git a/internal/api/dashboardspa/dist/assets/FormulaRunDetail-CFys0Xia.js b/internal/api/dashboardspa/dist/assets/FormulaRunDetail-CFys0Xia.js new file mode 100644 index 0000000000..cb33df4261 --- /dev/null +++ b/internal/api/dashboardspa/dist/assets/FormulaRunDetail-CFys0Xia.js @@ -0,0 +1,12 @@ +import{j as d,r as j,S as Tr,X as Pe,Y as Oe,Z as Mr,_ as Or,x as rn,p as tn,b as Yn,q as Ir,K as Rr,f as Pr,u as $r,$ as Fr,L as Br,B as Gr,H as xr,G as wn}from"./index-C20tCZFz.js";import{P as Lr}from"./PageHeader-D_D-jYn1.js";import{u as Ur,R as zr,B as Kr}from"./BeadDetailModal-BtVrX_Fu.js";import{u as Hr,S as Wr}from"./LiveSessionPeek-jm19JJ4Z.js";import{S as _n}from"./StageLadder-B3yZa5o4.js";import"./format-fte2CeYD.js";import"./Field-Dsl4x4KL.js";import"./constants-DBKWGg29.js";import"./time-D9v0saHV.js";const Vr=/^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,127}$/,Nn={pending:"pending",ready:"ready",running:"running",active:"running",done:"done",completed:"done",failed:"failed",blocked:"blocked",skipped:"skipped"};function Xr({node:e,selected:r,onToggle:n}){const t=Jr(e.constructKind),a=Qr(e.status),s=e.iterationSummary.kind==="stacked"?`${e.iterationSummary.iterationCount} iterations, showing ${e.iterationSummary.visibleIteration}`:null,i=e.attemptSummary.kind==="tracked"&&e.attemptSummary.badge.kind==="bounded"?` · attempt ${e.attemptSummary.badge.label}${Zr(e)}`:"";return d.jsxs("button",{type:"button","aria-pressed":r,onClick:()=>n(e.id),className:`focus-mark w-full text-left px-4 py-3 bg-transparent transition-colors duration-150 ease-out-quart ${t} ${r?"text-fg border-accent bg-surface-tint ring-2 ring-accent/45 ring-offset-2 ring-offset-surface":"text-fg border-rule hover:border-fg-faint hover:bg-surface-tint"}`,children:[d.jsxs("div",{className:"flex items-start justify-between gap-3",children:[d.jsxs("div",{children:[d.jsx("p",{className:"text-body text-fg leading-snug",children:e.title}),d.jsxs("p",{className:"mt-1 text-label uppercase tracking-wider text-fg-faint",children:[Yr(e.constructKind),i]})]}),d.jsxs("span",{className:`text-label uppercase tracking-wider shrink-0 ${a}`,children:[qr(e.status)," ",Nn[e.status]]})]}),s&&d.jsxs("p",{className:"mt-2 text-label uppercase tracking-wider text-fg-faint tnum",children:["stacked history: ",s]}),e.controlBadges.length>0&&d.jsx("div",{className:"mt-2 flex flex-wrap gap-2",children:e.controlBadges.map(o=>d.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-muted border border-rule px-1.5 py-0.5",children:[o.label,": ",Nn[o.status]]},o.id))})]})}function Zr(e){return e.attemptSummary.kind==="tracked"&&e.attemptSummary.active.kind==="running"?` · running attempt ${e.attemptSummary.active.value}`:""}function Yr(e){switch(e){case"run-root":return"run root";case"run-finalize":return"finalize";case"step":case"retry":case"check-loop":case"scope":case"condition":case"fanout":case"expansion":case"scope-check":case"spec":case"control":case"unknown":return e.replace(/-/g," ")}}function Jr(e){switch(e){case"run-root":return"formula-run-node-shape-root";case"step":case"unknown":return"formula-run-node-shape-step";case"retry":return"formula-run-node-shape-retry";case"check-loop":return"formula-run-node-shape-check-loop";case"scope":return"formula-run-node-shape-scope";case"condition":return"formula-run-node-shape-condition";case"fanout":return"formula-run-node-shape-fanout";case"expansion":return"formula-run-node-shape-expansion";case"scope-check":case"run-finalize":case"spec":case"control":return"formula-run-node-shape-control"}}function Qr(e){switch(e){case"failed":case"blocked":return"text-accent";case"active":case"running":case"ready":return"text-fg";case"completed":case"done":return"text-fg-muted";case"pending":case"skipped":return"text-fg-faint"}}function qr(e){switch(e){case"completed":case"done":return"✓";case"active":case"running":return"●";case"failed":case"blocked":return"!";case"skipped":return"∅";case"pending":case"ready":return"·"}}function et({detail:e,selectedNodeId:r,onToggleNode:n}){const t=nt(e),a=rt(e);return t.length===0?d.jsx("p",{className:"text-body text-fg-muted italic",children:"No graph nodes have materialized for this formula run."}):d.jsxs("section",{"aria-label":"Formula run graph",children:[d.jsx("div",{className:"flex items-baseline justify-between gap-4",children:d.jsx("h2",{className:"text-title text-fg",children:"Formula Graph"})}),d.jsx("ol",{className:"mt-5 space-y-3 relative",children:t.map((s,i)=>{const o=a.get(s.id),l=i>0?a.get(t[i-1]?.id??""):void 0,u=o!==void 0&&o!==l;return d.jsxs("li",{className:"relative pl-6",children:[u&&d.jsx("p",{className:"mb-1 text-label uppercase tracking-wider text-fg-faint",children:o}),ir.visibleInGraph!==!1)}function rt(e){const r=new Map;for(const n of e.lanes)for(const t of n.nodeIds)r.set(t,n.label);return r}function jn(e,r){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var t=Object.getOwnPropertySymbols(e);r&&(t=t.filter((function(a){return Object.getOwnPropertyDescriptor(e,a).enumerable}))),n.push.apply(n,t)}return n}function O(e){for(var r=1;r=0||(c[l]=i[l]);return c})(e,r);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(e);for(t=0;t=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(a[n]=e[n])}return a}function x(e,r){return at(e)||(function(n,t){var a=n==null?null:typeof Symbol<"u"&&n[Symbol.iterator]||n["@@iterator"];if(a!=null){var s,i,o,l,u=[],c=!0,f=!1;try{if(o=(a=a.call(n)).next,t===0){if(Object(a)!==a)return;c=!1}else for(;!(c=(s=o.call(a)).done)&&(u.push(s.value),u.length!==t);c=!0);}catch(h){f=!0,i=h}finally{try{if(!c&&a.return!=null&&(l=a.return(),Object(l)!==l))return}finally{if(f)throw i}}return u}})(e,r)||an(e,r)||it()}function tt(e){return(function(r){if(Array.isArray(r))return Ve(r)})(e)||st(e)||an(e)||(function(){throw new TypeError(`Invalid attempt to spread non-iterable instance. +In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)})()}function at(e){if(Array.isArray(e))return e}function st(e){if(typeof Symbol<"u"&&e[Symbol.iterator]!=null||e["@@iterator"]!=null)return Array.from(e)}function an(e,r){if(e){if(typeof e=="string")return Ve(e,r);var n=Object.prototype.toString.call(e).slice(8,-1);return n==="Object"&&e.constructor&&(n=e.constructor.name),n==="Map"||n==="Set"?Array.from(e):n==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?Ve(e,r):void 0}}function Ve(e,r){(r==null||r>e.length)&&(r=e.length);for(var n=0,t=new Array(r);n=e.length?{done:!0}:{done:!1,value:e[t++]}},e:function(l){throw l},f:a}}throw new TypeError(`Invalid attempt to iterate non-iterable instance. +In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}var s,i=!0,o=!1;return{s:function(){n=n.call(e)},n:function(){var l=n.next();return i=l.done,l},e:function(l){o=!0,s=l},f:function(){try{i||n.return==null||n.return()}finally{if(o)throw s}}}}var Ce=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function _e(e,r){return e(r={exports:{}},r.exports),r.exports}var F=_e((function(e){(function(){var r={}.hasOwnProperty;function n(){for(var t=[],a=0;a-1?p.slice(0,y):_;switch(_){case"diff":v--;break e;case"deleted":case"new":var N=p.slice(y+1);N.indexOf("file mode")===0&&(i[_==="new"?"newMode":"oldMode"]=N.slice(10));break;case"similarity":i.similarity=parseInt(p.split(" ")[2],10);break;case"index":var C=p.slice(y+1).split(" "),S=C[0].split("..");i.oldRevision=S[0],i.newRevision=S[1],C[1]&&(i.oldMode=i.newMode=C[1]);break;case"copy":case"rename":var A=p.slice(y+1);A.indexOf("from")===0?i.oldPath=A.slice(5):i.newPath=A.slice(3),w=_;break;case"---":var k=p.slice(y+1),E=g[++v].slice(4);k==="/dev/null"?(E=E.slice(2),w="add"):E==="/dev/null"?(k=k.slice(2),w="delete"):(w="modify",k=k.slice(2),E=E.slice(2)),k&&(i.oldPath=k),E&&(i.newPath=E),h=5;break e}}i.type=w||"modify"}else if(b.indexOf("Binary")===0)i.isBinary=!0,i.type=b.indexOf("/dev/null and")>=0?"add":b.indexOf("and /dev/null")>=0?"delete":"modify",h=2,i=null;else if(h===5)if(b.indexOf("@@")===0){var D=/^@@\s+-([0-9]+)(,([0-9]+))?\s+\+([0-9]+)(,([0-9]+))?/.exec(b);o={content:b,oldStart:D[1]-0,newStart:D[4]-0,oldLines:D[3]-0||1,newLines:D[6]-0||1,changes:[]},i.hunks.push(o),l=o.oldStart,u=o.newStart}else{var B=b.slice(0,1),M={content:b.slice(1)};switch(B){case"+":M.type="insert",M.isInsert=!0,M.lineNumber=u,u++;break;case"-":M.type="delete",M.isDelete=!0,M.lineNumber=l,l++;break;case" ":M.type="normal",M.isNormal=!0,M.oldLineNumber=l,M.newLineNumber=u,l++,u++;break;case"\\":var R=o.changes[o.changes.length-1];R.isDelete||(i.newEndingNewLine=!1),R.isInsert||(i.oldEndingNewLine=!1)}M.type&&o.changes.push(M)}v++}return f}};e.exports=a})()}));function Ne(e){return e.type==="insert"}function Q(e){return e.type==="delete"}function ve(e){return e.type==="normal"}function ft(e,r){var n=r.nearbySequences==="zip"?(function(t){var a=t.reduce((function(s,i,o){var l=x(s,3),u=l[0],c=l[1],f=l[2];return c?Ne(i)&&f>=0?(u.splice(f+1,0,i),[u,i,f+2]):(u.push(i),[u,i,Q(i)&&Q(c)?f:o]):(u.push(i),[u,i,Q(i)?o:-1])}),[[],null,-1]);return x(a,1)[0]})(e.changes):e.changes;return O(O({},e),{},{isPlain:!1,changes:n})}function dt(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},n=(function(t){if(t.startsWith("diff --git"))return t;var a=t.indexOf(` +`),s=t.indexOf(` +`,a+1),i=t.slice(0,a),o=t.slice(a+1,s),l=i.split(" ").slice(1,-3).join(" "),u=o.split(" ").slice(1,-3).join(" ");return["diff --git a/".concat(l," b/").concat(u),"index 1111111..2222222 100644","--- a/".concat(l),"+++ b/".concat(u),t.slice(s+1)].join(` +`)})(e.trimStart());return ct.parse(n).map((function(t){return(function(a,s){var i=a.hunks.map((function(o){return ft(o,s)}));return O(O({},a),{},{hunks:i})})(t,r)}))}function ht(e){return e[0]}function gt(e){return e[e.length-1]}function Xe(e){return["".concat(e,"Start"),"".concat(e,"Lines")]}function be(e){return e==="old"?function(r){return Ne(r)?-1:ve(r)?r.oldLineNumber:r.lineNumber}:function(r){return Q(r)?-1:ve(r)?r.newLineNumber:r.lineNumber}}function Qn(e,r){return function(n,t){var a=n[e],s=a+n[r];return t>=a&&t=s&&a-1},_t=function(e,r){var n=this.__data__,t=$e(n,e);return t<0?(++this.size,n.push([e,r])):n[t][1]=r,this};function ie(e){var r=-1,n=e==null?0:e.length;for(this.clear();++ro))return!1;var u=s.get(e),c=s.get(r);if(u&&c)return u==r&&c==e;var f=-1,h=!0,g=2&n?new aa:void 0;for(s.set(e,r),s.set(r,e);++f-1&&e%1==0&&e-1&&e%1==0&&e<=9007199254740991},T={};T["[object Float32Array]"]=T["[object Float64Array]"]=T["[object Int8Array]"]=T["[object Int16Array]"]=T["[object Int32Array]"]=T["[object Uint8Array]"]=T["[object Uint8ClampedArray]"]=T["[object Uint16Array]"]=T["[object Uint32Array]"]=!0,T["[object Arguments]"]=T["[object Array]"]=T["[object ArrayBuffer]"]=T["[object Boolean]"]=T["[object DataView]"]=T["[object Date]"]=T["[object Error]"]=T["[object Function]"]=T["[object Map]"]=T["[object Number]"]=T["[object Object]"]=T["[object RegExp]"]=T["[object Set]"]=T["[object String]"]=T["[object WeakMap]"]=!1;var _a=function(e){return de(e)&&ln(e.length)&&!!T[he(e)]},Na=function(e){return function(r){return e(r)}},Mn=_e((function(e,r){var n=r&&!r.nodeType&&r,t=n&&e&&!e.nodeType&&e,a=t&&t.exports===n&&nr.process,s=(function(){try{var i=t&&t.require&&t.require("util").types;return i||a&&a.binding&&a.binding("util")}catch{}})();e.exports=s})),On=Mn&&Mn.isTypedArray,lr=On?Na(On):_a,ja=Object.prototype.hasOwnProperty,Sa=function(e,r){var n=W(e),t=!n&&ir(e),a=!n&&!t&&Ze(e),s=!n&&!t&&!a&&lr(e),i=n||t||a||s,o=i?va(e.length,String):[],l=o.length;for(var u in e)!ja.call(e,u)||i&&(u=="length"||a&&(u=="offset"||u=="parent")||s&&(u=="buffer"||u=="byteLength"||u=="byteOffset")||or(u,l))||o.push(u);return o},ka=Object.prototype,Ca=function(e){var r=e&&e.constructor;return e===(typeof r=="function"&&r.prototype||ka)},Aa=(function(e,r){return function(n){return e(r(n))}})(Object.keys,Object),Ea=Object.prototype.hasOwnProperty,Da=function(e){if(!Ca(e))return Aa(e);var r=[];for(var n in Object(e))Ea.call(e,n)&&n!="constructor"&&r.push(n);return r},Ta=function(e){return e!=null&&ln(e.length)&&!tr(e)},un=function(e){return Ta(e)?Sa(e):Da(e)},In=function(e){return fa(e,un,ma)},Ma=Object.prototype.hasOwnProperty,Oa=function(e,r,n,t,a,s){var i=1&n,o=In(e),l=o.length;if(l!=In(r).length&&!i)return!1;for(var u=l;u--;){var c=o[u];if(!(i?c in r:Ma.call(r,c)))return!1}var f=s.get(e),h=s.get(r);if(f&&h)return f==r&&h==e;var g=!0;s.set(e,r),s.set(r,e);for(var m=i;++u1)return!1;if(e.length===1){var r=x(e,1)[0];return r.type==="text"&&!r.value}return!0}function hs(e){var r=e.changeKey,n=e.text,t=e.tokens,a=e.renderToken,s=fe(e,fs),i=a?function(o,l){return a(o,Gn,l)}:Gn;return d.jsx("td",O(O({},s),{},{"data-change-key":r,children:t?ds(t)?" ":t.map(i):n||" "}))}var mr=j.memo(hs);function vr(e,r){return function(){var n=r==="old"?hn(e):gn(e);return n===-1?void 0:n}}function br(e,r){return function(n){return e&&n?d.jsx("a",{href:r?"#"+r:void 0,children:n}):n}}function Ie(e,r){return r?function(n){e(),r(n)}:e}function xn(e,r,n,t){return j.useMemo((function(){var a=gr(e,(function(s){return function(i){return s&&s(r,i)}}));return a.onMouseEnter=Ie(n,a.onMouseEnter),a.onMouseLeave=Ie(t,a.onMouseLeave),a}),[e,n,t,r])}function Ln(e,r,n,t,a,s,i,o,l){var u={change:r,side:t,inHoverState:o,renderDefault:vr(r,t),wrapInAnchor:br(a,s)};return d.jsx("td",O(O({className:e},i),{},{"data-change-key":n,children:l(u)}))}function gs(e){var r,n,t,a=e.change,s=e.selected,i=e.tokens,o=e.className,l=e.generateLineClassName,u=e.gutterClassName,c=e.codeClassName,f=e.gutterEvents,h=e.codeEvents,g=e.hideGutter,m=e.gutterAnchor,v=e.generateAnchorID,b=e.renderToken,p=e.renderGutter,w=a.type,y=a.content,_=q(a),N=(r=x(j.useState(!1),2),n=r[0],t=r[1],[n,j.useCallback((function(){return t(!0)}),[]),j.useCallback((function(){return t(!1)}),[])]),C=x(N,3),S=C[0],A=C[1],k=C[2],E=j.useMemo((function(){return{change:a}}),[a]),D=xn(f,E,A,k),B=xn(h,E,A,k),M=v(a),R=l({changes:[a],defaultGenerate:function(){return o}}),G=F("diff-gutter","diff-gutter-".concat(w),u,{"diff-gutter-selected":s}),L=F("diff-code","diff-code-".concat(w),c,{"diff-code-selected":s});return d.jsxs("tr",{id:M,className:F("diff-line",R),children:[!g&&Ln(G,a,_,"old",m,M,D,S,p),!g&&Ln(G,a,_,"new",m,M,D,S,p),d.jsx(mr,O({className:L,changeKey:_,text:y,tokens:i,renderToken:b},B))]})}var ms=j.memo(gs);function vs(e){var r=e.hideGutter,n=e.element;return d.jsx("tr",{className:"diff-widget",children:d.jsx("td",{colSpan:r?1:3,className:"diff-widget-content",children:n})})}var bs=["hideGutter","selectedChanges","tokens","lineClassName"],ps=["hunk","widgets","className"];function ys(e){var r=e.hunk,n=e.widgets,t=e.className,a=fe(e,ps),s=(function(i,o){return i.reduce((function(l,u){var c=q(u);l.push(["change",c,u]);var f=o[c];return f&&l.push(["widget",c,f]),l}),[])})(r.changes,n);return d.jsx("tbody",{className:F("diff-hunk",t),children:s.map((function(i){return(function(o,l){var u=x(o,3),c=u[0],f=u[1],h=u[2],g=l.hideGutter,m=l.selectedChanges,v=l.tokens,b=l.lineClassName,p=fe(l,bs);if(c==="change"){var w=Q(h)?"old":"new",y=Q(h)?hn(h):gn(h),_=v?v[w][y-1]:null;return d.jsx(ms,O({className:b,change:h,hideGutter:g,selected:m.includes(f),tokens:_},p),"change".concat(f))}return c==="widget"?d.jsx(vs,{hideGutter:g,element:h},"widget".concat(f)):null})(i,a)}))})}var pr=0;function Ee(e,r,n,t){var a=j.useCallback((function(){return r(e)}),[e,r]),s=j.useCallback((function(){return r("")}),[r]);return j.useMemo((function(){var i=gr(t,(function(o){return function(l){return o&&o({side:e,change:n},l)}}));return i.onMouseEnter=Ie(a,i.onMouseEnter),i.onMouseLeave=Ie(s,i.onMouseLeave),i}),[n,t,a,e,s])}function Ke(e){var r=e.change,n=e.side,t=e.selected,a=e.tokens,s=e.gutterClassName,i=e.codeClassName,o=e.gutterEvents,l=e.codeEvents,u=e.anchorID,c=e.gutterAnchor,f=e.gutterAnchorTarget,h=e.hideGutter,g=e.hover,m=e.renderToken,v=e.renderGutter;if(!r){var b=F("diff-gutter","diff-gutter-omit",s),p=F("diff-code","diff-code-omit",i);return[!h&&d.jsx("td",{className:b},"gutter"),d.jsx("td",{className:p},"code")]}var w=r.type,y=r.content,_=q(r),N=n===pr?"old":"new",C=O({id:u||void 0,className:F("diff-gutter","diff-gutter-".concat(w),We({"diff-gutter-selected":t},"diff-line-hover-"+N,g),s),children:v({change:r,side:N,inHoverState:g,renderDefault:vr(r,N),wrapInAnchor:br(c,f)})},o),S=F("diff-code","diff-code-".concat(w),We({"diff-code-selected":t},"diff-line-hover-"+N,g),i);return[!h&&d.jsx("td",O(O({},C),{},{"data-change-key":_}),"gutter"),d.jsx(mr,O({className:S,changeKey:_,text:y,tokens:a,renderToken:m},l),"code")]}function ws(e){var r=e.className,n=e.oldChange,t=e.newChange,a=e.oldSelected,s=e.newSelected,i=e.oldTokens,o=e.newTokens,l=e.monotonous,u=e.gutterClassName,c=e.codeClassName,f=e.gutterEvents,h=e.codeEvents,g=e.hideGutter,m=e.generateAnchorID,v=e.generateLineClassName,b=e.gutterAnchor,p=e.renderToken,w=e.renderGutter,y=x(j.useState(""),2),_=y[0],N=y[1],C=Ee("old",N,n,f),S=Ee("new",N,t,f),A=Ee("old",N,n,h),k=Ee("new",N,t,h),E=n&&m(n),D=t&&m(t),B=v({changes:[n,t],defaultGenerate:function(){return r}}),M={monotonous:l,hideGutter:g,gutterClassName:u,codeClassName:c,gutterEvents:f,codeEvents:h,renderToken:p,renderGutter:w},R=O(O({},M),{},{change:n,side:pr,selected:a,tokens:i,gutterEvents:C,codeEvents:A,anchorID:E,gutterAnchor:b,gutterAnchorTarget:E,hover:_==="old"}),G=O(O({},M),{},{change:t,side:1,selected:s,tokens:o,gutterEvents:S,codeEvents:k,anchorID:n===t?null:D,gutterAnchor:b,gutterAnchorTarget:n===t?E:D,hover:_==="new"});if(l)return d.jsx("tr",{className:F("diff-line",B),children:Ke(n?R:G)});var L=(function(X,ee){return X&&!ee?"diff-line-old-only":!X&&ee?"diff-line-new-only":X===ee?"diff-line-normal":"diff-line-compare"})(n,t);return d.jsxs("tr",{className:F("diff-line",L,B),children:[Ke(R),Ke(G)]})}var _s=j.memo(ws);function Ns(e){var r=e.hideGutter,n=e.oldElement,t=e.newElement;return e.monotonous?d.jsx("tr",{className:"diff-widget",children:d.jsx("td",{colSpan:r?1:2,className:"diff-widget-content",children:n||t})}):n===t?d.jsx("tr",{className:"diff-widget",children:d.jsx("td",{colSpan:r?2:4,className:"diff-widget-content",children:n})}):d.jsxs("tr",{className:"diff-widget",children:[d.jsx("td",{colSpan:r?1:2,className:"diff-widget-content",children:n}),d.jsx("td",{colSpan:r?1:2,className:"diff-widget-content",children:t})]})}var js=["selectedChanges","monotonous","hideGutter","tokens","lineClassName"],Ss=["hunk","widgets","className"];function De(e,r){return(e?q(e):"00")+(r?q(r):"00")}function ks(e){var r=e.hunk,n=e.widgets,t=e.className,a=fe(e,Ss),s=(function(i,o){for(var l=function(p){if(!p)return null;var w=q(p);return o[w]||null},u=[],c=0;ct.length?n:t,l=n.length>t.length?t:n,u=o.indexOf(l);if(u!=-1)return i=[new r.Diff(1,o.substring(0,u)),new r.Diff(0,l),new r.Diff(1,o.substring(u+l.length))],n.length>t.length&&(i[0][0]=i[2][0]=-1),i;if(l.length==1)return[new r.Diff(-1,n),new r.Diff(1,t)];var c=this.diff_halfMatch_(n,t);if(c){var f=c[0],h=c[1],g=c[2],m=c[3],v=c[4],b=this.diff_main(f,g,a,s),p=this.diff_main(h,m,a,s);return b.concat([new r.Diff(0,v)],p)}return a&&n.length>100&&t.length>100?this.diff_lineMode_(n,t,s):this.diff_bisect_(n,t,s)},r.prototype.diff_lineMode_=function(n,t,a){var s=this.diff_linesToChars_(n,t);n=s.chars1,t=s.chars2;var i=s.lineArray,o=this.diff_main(n,t,!1,a);this.diff_charsToLines_(o,i),this.diff_cleanupSemantic(o),o.push(new r.Diff(0,""));for(var l=0,u=0,c=0,f="",h="";l=1&&c>=1){o.splice(l-u-c,u+c),l=l-u-c;for(var g=this.diff_main(f,h,!1,a),m=g.length-1;m>=0;m--)o.splice(l,0,g[m]);l+=g.length}c=0,u=0,f="",h=""}l++}return o.pop(),o},r.prototype.diff_bisect_=function(n,t,a){for(var s=n.length,i=t.length,o=Math.ceil((s+i)/2),l=o,u=2*o,c=new Array(u),f=new Array(u),h=0;ha);y++){for(var _=-y+v;_<=y-b;_+=2){for(var N=l+_,C=(D=_==-y||_!=y&&c[N-1]s)b+=2;else if(C>i)v+=2;else if(m&&(k=l+g-_)>=0&&k=(A=s-f[k]))return this.diff_bisectSplit_(n,t,D,C,a)}for(var S=-y+p;S<=y-w;S+=2){for(var A,k=l+S,E=(A=S==-y||S!=y&&f[k-1]s)w+=2;else if(E>i)p+=2;else if(!m&&(N=l+g-S)>=0&&N=(A=s-A))return this.diff_bisectSplit_(n,t,D,C,a)}}}return[new r.Diff(-1,n),new r.Diff(1,t)]},r.prototype.diff_bisectSplit_=function(n,t,a,s,i){var o=n.substring(0,a),l=t.substring(0,s),u=n.substring(a),c=t.substring(s),f=this.diff_main(o,l,!1,i),h=this.diff_main(u,c,!1,i);return f.concat(h)},r.prototype.diff_linesToChars_=function(n,t){var a=[],s={};function i(u){for(var c="",f=0,h=-1,g=a.length;hs?n=n.substring(a-s):at.length?n:t,s=n.length>t.length?t:n;if(a.length<4||2*s.length=v.length?[w,y,_,N,A]:null}var l,u,c,f,h,g=o(a,s,Math.ceil(a.length/4)),m=o(a,s,Math.ceil(a.length/2));return g||m?(l=m?g&&g[4].length>m[4].length?g:m:g,n.length>t.length?(u=l[0],c=l[1],f=l[2],h=l[3]):(f=l[0],h=l[1],u=l[2],c=l[3]),[u,c,f,h,l[4]]):null},r.prototype.diff_cleanupSemantic=function(n){for(var t=!1,a=[],s=0,i=null,o=0,l=0,u=0,c=0,f=0;o0?a[s-1]:-1,l=0,u=0,c=0,f=0,i=null,t=!0)),o++;for(t&&this.diff_cleanupMerge(n),this.diff_cleanupSemanticLossless(n),o=1;o=v?(m>=h.length/2||m>=g.length/2)&&(n.splice(o,0,new r.Diff(0,g.substring(0,m))),n[o-1][1]=h.substring(0,h.length-m),n[o+1][1]=g.substring(m),o++):(v>=h.length/2||v>=g.length/2)&&(n.splice(o,0,new r.Diff(0,h.substring(0,v))),n[o-1][0]=1,n[o-1][1]=g.substring(0,g.length-v),n[o+1][0]=-1,n[o+1][1]=h.substring(v),o++),o++}o++}},r.prototype.diff_cleanupSemanticLossless=function(n){function t(v,b){if(!v||!b)return 6;var p=v.charAt(v.length-1),w=b.charAt(0),y=p.match(r.nonAlphaNumericRegex_),_=w.match(r.nonAlphaNumericRegex_),N=y&&p.match(r.whitespaceRegex_),C=_&&w.match(r.whitespaceRegex_),S=N&&p.match(r.linebreakRegex_),A=C&&w.match(r.linebreakRegex_),k=S&&v.match(r.blanklineEndRegex_),E=A&&b.match(r.blanklineStartRegex_);return k||E?5:S||A?4:y&&!N&&C?3:N||C?2:y||_?1:0}for(var a=1;a=g&&(g=m,c=s,f=i,h=o)}n[a-1][1]!=c&&(c?n[a-1][1]=c:(n.splice(a-1,1),a--),n[a][1]=f,h?n[a+1][1]=h:(n.splice(a+1,1),a--))}a++}},r.nonAlphaNumericRegex_=/[^a-zA-Z0-9]/,r.whitespaceRegex_=/\s/,r.linebreakRegex_=/[\r\n]/,r.blanklineEndRegex_=/\n\r?\n$/,r.blanklineStartRegex_=/^\r?\n\r?\n/,r.prototype.diff_cleanupEfficiency=function(n){for(var t=!1,a=[],s=0,i=null,o=0,l=!1,u=!1,c=!1,f=!1;o0?a[s-1]:-1,c=f=!1),t=!0)),o++;t&&this.diff_cleanupMerge(n)},r.prototype.diff_cleanupMerge=function(n){n.push(new r.Diff(0,""));for(var t,a=0,s=0,i=0,o="",l="";a1?(s!==0&&i!==0&&((t=this.diff_commonPrefix(l,o))!==0&&(a-s-i>0&&n[a-s-i-1][0]==0?n[a-s-i-1][1]+=l.substring(0,t):(n.splice(0,0,new r.Diff(0,l.substring(0,t))),a++),l=l.substring(t),o=o.substring(t)),(t=this.diff_commonSuffix(l,o))!==0&&(n[a][1]=l.substring(l.length-t)+n[a][1],l=l.substring(0,l.length-t),o=o.substring(0,o.length-t))),a-=s+i,n.splice(a,s+i),o.length&&(n.splice(a,0,new r.Diff(-1,o)),a++),l.length&&(n.splice(a,0,new r.Diff(1,l)),a++),a++):a!==0&&n[a-1][0]==0?(n[a-1][1]+=n[a][1],n.splice(a,1)):a++,i=0,s=0,o="",l=""}n[n.length-1][1]===""&&n.pop();var u=!1;for(a=1;at));a++)o=s,l=i;return n.length!=a&&n[a][0]===-1?l:l+(t-o)},r.prototype.diff_prettyHtml=function(n){for(var t=[],a=/&/g,s=//g,o=/\n/g,l=0;l");switch(u){case 1:t[l]=''+c+"";break;case-1:t[l]=''+c+"";break;case 0:t[l]=""+c+""}}return t.join("")},r.prototype.diff_text1=function(n){for(var t=[],a=0;athis.Match_MaxBits)throw new Error("Pattern too long for this browser.");var s=this.match_alphabet_(t),i=this;function o(C,S){var A=C/t.length,k=Math.abs(a-S);return i.Match_Distance?A+k/i.Match_Distance:k?1:A}var l=this.Match_Threshold,u=n.indexOf(t,a);u!=-1&&(l=Math.min(o(0,u),l),(u=n.lastIndexOf(t,a+t.length))!=-1&&(l=Math.min(o(0,u),l)));var c,f,h=1<=b;y--){var _=s[n.charAt(y-1)];if(w[y]=v===0?(w[y+1]<<1|1)&_:(w[y+1]<<1|1)&_|(g[y+1]|g[y])<<1|1|g[y+1],w[y]&h){var N=o(v,y-1);if(N<=l){if(l=N,!((u=y-1)>a))break;b=Math.max(1,2*a-u)}}}if(o(v+1,a)>l)break;g=w}return u},r.prototype.match_alphabet_=function(n){for(var t={},a=0;a2&&(this.diff_cleanupSemantic(i),this.diff_cleanupEfficiency(i));else if(n&&typeof n=="object"&&t===void 0&&a===void 0)i=n,s=this.diff_text1(i);else if(typeof n=="string"&&t&&typeof t=="object"&&a===void 0)s=n,i=t;else{if(typeof n!="string"||typeof t!="string"||!a||typeof a!="object")throw new Error("Unknown call format to patch_make.");s=n,i=a}if(i.length===0)return[];for(var o=[],l=new r.patch_obj,u=0,c=0,f=0,h=s,g=s,m=0;m=2*this.Patch_Margin&&u&&(this.patch_addContext_(l,h),o.push(l),l=new r.patch_obj,u=0,h=g,c=f)}v!==1&&(c+=b.length),v!==-1&&(f+=b.length)}return u&&(this.patch_addContext_(l,h),o.push(l)),o},r.prototype.patch_deepCopy=function(n){for(var t=[],a=0;athis.Match_MaxBits?(l=this.match_main(t,f.substring(0,this.Match_MaxBits),c))!=-1&&((h=this.match_main(t,f.substring(f.length-this.Match_MaxBits),c+f.length-this.Match_MaxBits))==-1||l>=h)&&(l=-1):l=this.match_main(t,f,c),l==-1)i[o]=!1,s-=n[o].length2-n[o].length1;else if(i[o]=!0,s=l-c,f==(u=h==-1?t.substring(l,l+f.length):t.substring(l,h+this.Match_MaxBits)))t=t.substring(0,l)+this.diff_text2(n[o].diffs)+t.substring(l+f.length);else{var g=this.diff_main(f,u,!1);if(f.length>this.Match_MaxBits&&this.diff_levenshtein(g)/f.length>this.Patch_DeleteThreshold)i[o]=!1;else{this.diff_cleanupSemanticLossless(g);for(var m,v=0,b=0;bo[0][1].length){var l=t-o[0][1].length;o[0][1]=a.substring(o[0][1].length)+o[0][1],i.start1-=l,i.start2-=l,i.length1+=l,i.length2+=l}return(o=(i=n[n.length-1]).diffs).length==0||o[o.length-1][0]!=0?(o.push(new r.Diff(0,a)),i.length1+=t,i.length2+=t):t>o[o.length-1][1].length&&(l=t-o[o.length-1][1].length,o[o.length-1][1]+=a.substring(0,l),i.length1+=l,i.length2+=l),a},r.prototype.patch_splitMax=function(n){for(var t=this.Match_MaxBits,a=0;a2*t?(u.length1+=h.length,i+=h.length,c=!1,u.diffs.push(new r.Diff(f,h)),s.diffs.shift()):(h=h.substring(0,t-u.length1-this.Patch_Margin),u.length1+=h.length,i+=h.length,f===0?(u.length2+=h.length,o+=h.length):c=!1,u.diffs.push(new r.Diff(f,h)),h==s.diffs[0][1]?s.diffs.shift():s.diffs[0][1]=s.diffs[0][1].substring(h.length))}l=(l=this.diff_text2(u.diffs)).substring(l.length-this.Patch_Margin);var g=this.diff_text1(s.diffs).substring(0,this.Patch_Margin);g!==""&&(u.length1+=g.length,u.length2+=g.length,u.diffs.length!==0&&u.diffs[u.diffs.length-1][0]===0?u.diffs[u.diffs.length-1][1]+=g:u.diffs.push(new r.Diff(0,g))),c||n.splice(++a,0,u)}}},r.prototype.patch_toText=function(n){for(var t=[],a=0;aRs(e.patch),[e.patch]);return d.jsxs("section",{children:[d.jsxs("div",{className:"flex items-baseline justify-between gap-4 flex-wrap",children:[d.jsx("h3",{className:"text-body font-semibold text-fg",children:"Local Changes"}),d.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:[e.changedFiles.length," changed file",e.changedFiles.length===1?"":"s"]})]}),e.rootPath.kind==="known"&&d.jsx("p",{className:"mt-1 text-label text-fg-faint break-all",children:e.rootPath.path}),d.jsx("p",{className:"mt-2 text-label uppercase tracking-wider text-fg-muted",children:$s(e.comparison)}),r.length===0?d.jsx("p",{className:"mt-5 text-body text-fg-muted italic",children:"No renderable patch in this work tree."}):d.jsx("div",{className:"formula-run-diff-view mt-5 space-y-3",children:r.map(n=>d.jsx(Is,{file:n},`${n.oldRevision}:${n.newRevision}:${wr(n)}`))}),e.truncated&&d.jsx("p",{className:"mt-3 text-label uppercase tracking-wider text-fg-faint",children:"Diff truncated at the backend output cap."})]})}function Is({file:e}){const r=Fs(e.hunks);return d.jsxs("details",{className:"border-y border-rule py-2",open:!0,children:[d.jsxs("summary",{className:"cursor-pointer list-none text-label uppercase tracking-wider text-fg-muted",children:[d.jsx("span",{className:"font-medium normal-case tracking-normal text-body text-fg",children:wr(e)}),d.jsxs("span",{className:"ml-3 tnum text-fg-faint",children:["+",r.additions," -",r.deletions]})]}),e.hunks.length===0||e.isBinary?d.jsx("p",{className:"mt-3 text-body text-fg-muted italic",children:"No textual hunks."}):d.jsx("div",{className:"mt-3 overflow-auto",children:d.jsx(Ts,{viewType:"unified",diffType:e.type,hunks:e.hunks,renderGutter:Ps,children:n=>n.map(t=>d.jsx(yr,{hunk:t},Bs(t)))})})]})}function Rs(e){if(e.trim().length===0)return[];try{return dt(e,{nearbySequences:"zip"})}catch{return[]}}function Ps({change:e,side:r,renderDefault:n}){return e.type==="insert"&&r==="old"?d.jsx("span",{className:"diff-gutter-sign","aria-hidden":!0,children:"+"}):e.type==="delete"&&r==="new"?d.jsx("span",{className:"diff-gutter-sign","aria-hidden":!0,children:"-"}):n()}function $s(e){return e.kind==="upstream"?`Compared with ${e.ref} at ${e.mergeBase.slice(0,12)}.`:e.kind==="head"&&e.reason==="no_upstream"?"No upstream branch is configured; showing changes relative to HEAD plus untracked files.":e.kind==="head"?"Upstream comparison failed; showing changes relative to HEAD plus untracked files.":"Comparison unavailable."}function wr(e){const r=zn(e.oldPath),n=zn(e.newPath);return e.type==="delete"?r:e.type==="rename"&&r!==n?`${r} -> ${n}`:n||r}function zn(e){return e.replace(/^[ab]\//,"")}function Fs(e){let r=0,n=0;for(const t of e)for(const a of t.changes)a.type==="insert"&&(r+=1),a.type==="delete"&&(n+=1);return{additions:r,deletions:n}}function Bs(e){return`${e.oldStart}:${e.newStart}:${e.content}`}function Gs({node:e,visible:r}){const n=j.useMemo(()=>e?.executionInstances.sort(Nr)??[],[e]),t=j.useMemo(()=>zs(e?.visibleExecutionInstanceId,n),[e?.visibleExecutionInstanceId,n]),[a,s]=j.useState(null);if(j.useEffect(()=>{s(t?z(t):null)},[e?.id,t]),!e)return d.jsx("p",{className:"text-body text-fg-muted italic",children:"Select a node to inspect its session."});if(n.length===0)return d.jsx("p",{className:"text-body text-fg-muted italic",children:Kn(e)});const i=n.find(c=>z(c)===a)??t??n[0],o=i?we(i):"base",l=Ks(n),u=n.filter(c=>we(c)===o);return i?d.jsxs("section",{children:[d.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[d.jsx("h3",{className:"text-body font-semibold text-fg",children:e.title}),(e.historicalOnly||i?.historical)&&d.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint",children:e.historicalOnly?"historical-only":"historical"})]}),l.length>1&&d.jsxs("div",{className:"mt-3 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-label",role:"radiogroup","aria-label":"Iterations",children:[d.jsx("span",{className:"uppercase tracking-wider text-fg-faint",children:"Iterations"}),l.map(c=>{const f=c.instances.at(-1);if(!f)return null;const h=c.iteration==="base"?"Base":`Iteration ${c.iteration}`,g=c.iteration===o;return d.jsxs("span",{className:"flex items-baseline gap-1",children:[d.jsx("span",{"aria-hidden":!0,className:"text-fg-faint",children:"·"}),d.jsx("button",{type:"button",role:"radio","aria-checked":g,className:`focus-mark rounded-sm px-0.5 uppercase tracking-wider ${g?"text-fg font-semibold underline decoration-fg underline-offset-4":"text-fg-muted hover:text-fg"}`,onClick:()=>s(z(f)),children:h})]},h)})]}),u.length>1&&d.jsxs("div",{className:"mt-2 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-label",role:"radiogroup","aria-label":"Attempts",children:[d.jsx("span",{className:"uppercase tracking-wider text-fg-faint",children:"Attempts"}),u.map(c=>d.jsxs("span",{className:"flex items-baseline gap-1",children:[d.jsx("span",{"aria-hidden":!0,className:"text-fg-faint",children:"·"}),d.jsxs("button",{type:"button",role:"radio","aria-checked":z(c)===z(i),className:`focus-mark rounded-sm px-0.5 uppercase tracking-wider ${z(c)===z(i)?"text-fg font-semibold underline decoration-fg underline-offset-4":"text-fg-muted hover:text-fg"}`,onClick:()=>s(z(c)),children:["Attempt ",en(c)]})]},z(c)))]}),d.jsxs("dl",{className:"mt-4 grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-1 text-label",children:[d.jsx("dt",{className:"uppercase tracking-wider text-fg-faint",children:"Execution instance"}),d.jsx("dd",{className:"break-all text-fg-muted tnum",children:i.id}),d.jsx("dt",{className:"uppercase tracking-wider text-fg-faint",children:"Bead"}),d.jsx("dd",{className:"break-all text-fg-muted tnum",children:i.beadId})]}),d.jsx(xs,{instance:i,visible:r})]}):d.jsx("p",{className:"text-body text-fg-muted italic",children:Kn(e)})}function xs({instance:e,visible:r}){const n=e.session.kind==="attached"?e.session:null,t=n?.link.sessionId??null,a=r&&!!n?.streamable,s=Hr(t,a);if(n===null)return d.jsx("p",{className:"mt-5 text-body text-fg-muted italic",children:Us(e)});const i=Ls(s.stream),o=s.status==="loading",l=s.status==="ready"?s.result:null,u=s.status==="failed"?s.error:null,c=s.status==="ready"&&s.stream.status==="degraded"?s.stream.error:null;return d.jsxs("div",{className:"mt-5 space-y-4",children:[n?.streamable&&d.jsx("div",{className:"flex justify-end",children:d.jsx(Tr,{tone:i.tone,label:i.label,title:`Session stream: ${s.stream.status}`,className:"text-label uppercase tracking-wider"})}),c!==null&&d.jsx("p",{className:"text-accent",role:"alert",children:c}),d.jsx(Wr,{loading:o,error:u,result:l})]})}function Ls(e){switch(e.status){case"open":return{tone:"ok",label:"live"};case"connecting":return{tone:"warn",label:"connecting"};case"closed":return{tone:"stuck",label:"offline"};case"degraded":return{tone:"warn",label:"degraded"};case"idle":return{tone:"neutral",label:"snapshot"}}}function Kn(e){const r=e.executionInstances.filter(t=>t.session.kind==="none");return r.some(t=>t.currentIteration&&t.session.kind==="none"&&t.session.reason==="session_unresolved"&&_r(t.status))?"Session unresolved for the current running node.":r.some(t=>t.session.kind==="none"&&t.session.reason==="session_unresolved")?"Session unresolved for this node.":"This node has not started a session yet."}function Us(e){return e.session.kind==="attached"?"":e.currentIteration&&e.session.reason==="session_unresolved"&&_r(e.status)?"Session unresolved for the current running node.":e.session.reason==="session_unresolved"?"Session unresolved for this node.":"This node has not started a session yet."}function _r(e){return e==="active"||e==="running"}function zs(e,r){return(e?r.find(t=>z(t)===e):void 0)??r.at(-1)}function Ks(e){const r=new Map;for(const n of e){const t=we(n);r.set(t,[...r.get(t)??[],n])}return[...r.entries()].map(([n,t])=>({iteration:n,instances:t.sort(Nr)})).sort((n,t)=>Re(n.iteration)-Re(t.iteration))}function Nr(e,r){return Re(we(e))-Re(we(r))||en(e)-en(r)||e.id.localeCompare(r.id)}function z(e){return e.id}function we(e){return e.iteration.kind==="loop"?e.iteration.value:"base"}function Re(e){return e==="base"?0:e}function en(e){return e.attempt.kind==="attempt"?e.attempt.value:1}function Hs({tab:e,diff:r,selectedNode:n}){return e==="session"?d.jsx(Gs,{node:n,visible:!0}):d.jsx(Ws,{diff:r})}function Ws({diff:e}){switch(e.kind){case"idle":return d.jsx("p",{className:"text-body text-fg-muted italic",children:"Local changes are not loaded for this run."});case"loading":return d.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading local changes."});case"failed":return d.jsx("p",{className:"text-body text-accent",role:"alert",children:e.error});case"ready":return d.jsxs(d.Fragment,{children:[e.refreshState.kind==="failed"&&d.jsx("p",{className:"mb-4 text-body text-accent",role:"alert",children:e.refreshState.error}),e.refreshState.kind==="refreshing"&&d.jsx("p",{className:"mb-4 text-label uppercase tracking-wider text-fg-faint",role:"status",children:"Refreshing local changes"}),d.jsx(Ms,{diff:e.diff})]})}}function Vs({diff:e,selectedNode:r,activeTab:n,onActiveTabChange:t}){const[a,s]=j.useState("diff"),i=n!==void 0&&t!==void 0,o=i?n:a,l=c=>{i?t(c):s(c)},u=`run-evidence-tab-${o}`;return d.jsxs("section",{"aria-label":"Run evidence",children:[d.jsxs("div",{className:"flex items-baseline gap-2 text-label",role:"tablist","aria-label":"Run evidence views",children:[d.jsx(Hn,{id:"run-evidence-tab-diff",controls:"run-evidence-panel",active:o==="diff",onClick:()=>l("diff"),children:"Diff"}),d.jsx("span",{"aria-hidden":!0,className:"text-fg-faint",children:"·"}),d.jsx(Hn,{id:"run-evidence-tab-session",controls:"run-evidence-panel",active:o==="session",onClick:()=>l("session"),children:"Session"})]}),d.jsx("div",{id:"run-evidence-panel",role:"tabpanel","aria-labelledby":u,className:"pt-5",children:d.jsx(Hs,{tab:o,diff:e,selectedNode:r})})]})}function Hn({id:e,controls:r,active:n,disabled:t=!1,onClick:a,children:s}){return d.jsx("button",{id:e,type:"button",role:"tab","aria-selected":n,"aria-controls":r,"aria-disabled":t||void 0,disabled:t,className:`focus-mark rounded-sm px-0.5 uppercase tracking-wider ${t?"cursor-not-allowed text-fg-faint":n?"text-fg font-semibold underline decoration-fg underline-offset-4":"text-fg-muted hover:text-fg"}`,onClick:a,children:s})}function Xs(e,r){const n=e.runIds.size===0||e.runIds.has(r.runId),t=e.rootBeadIds.size===0||e.rootBeadIds.has(r.rootBeadId);return n&&t}function Zs(e){const r={runIds:new Set,rootBeadIds:new Set};return J(e,r),J($(e.run),r),J($(e.payload),r),J($($(e.payload)?.run),r),J($(e.bead),r),J($($(e.payload)?.bead),r),J($(e.root),r),J($($(e.payload)?.root),r),nn($(e.metadata),r),nn($($(e.payload)?.metadata),r),r}function J(e,r){e&&(H(r.runIds,e.run_id),H(r.runIds,e.workflow_id),H(r.rootBeadIds,e.root_bead_id),nn($(e.metadata),r))}function nn(e,r){e&&(H(r.runIds,e["gc.run_id"]),H(r.runIds,e["gc.workflow_id"]),H(r.runIds,e.run_id),H(r.runIds,e.workflow_id),H(r.rootBeadIds,e["gc.root_bead_id"]),H(r.rootBeadIds,e.root_bead_id))}function H(e,r){if(typeof r!="string")return;const n=r.trim();n&&e.add(n)}function $(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)?e:void 0}function Ys(e,r,n){const[t,a]=j.useState({nodeId:null,routeKey:"",source:"route"});j.useEffect(()=>{if(!e)return;const u=Js(e,r);a(c=>c.routeKey===n&&(c.source==="user"||c.nodeId===u)?c:{nodeId:u,routeKey:n,source:"route"})},[e,n,r]);const s=j.useCallback(()=>{a(u=>({nodeId:null,routeKey:u.routeKey,source:"user"}))},[]);j.useEffect(()=>{const u=c=>{c.key==="Escape"&&s()};return window.addEventListener("keydown",u),()=>window.removeEventListener("keydown",u)},[s]);const i=j.useCallback(u=>{a(c=>({nodeId:c.nodeId===u?null:u,routeKey:n,source:"user"}))},[n]),o=t.nodeId,l=j.useMemo(()=>e?.nodes.find(u=>u.id===o)??null,[e,o]);return{selectedNodeId:o,selectedNode:l,toggleNode:i,clearSelection:s}}function Js(e,r){return r&&e.nodes.some(n=>n.id===r)?r:null}const Wn=[600,1200,2400],Qs=5e3,qs=18e4;async function ei(e,r){let n=0;for(let t=0;;t+=1)try{return await Pe.runDetail(e)}catch(a){const s=ni(a,t,n);if(s===void 0||r?.keepPolling?.()===!1||(jr(a)&&r?.onWarming?.({reason:a.reason}),n+=s,await ti(s),r?.keepPolling?.()===!1))throw a}}function ni(e,r,n){if(jr(e)){const t=Wn[r]??Qs;return n+t<=qs?t:void 0}return ri(e)?Wn[r]:void 0}function jr(e){return e instanceof Oe&&e.status===503}function ri(e){return e instanceof Oe?e.status>=500:e instanceof TypeError}function ti(e){return new Promise(r=>setTimeout(r,e))}function ai(e,r,n,t,a){const[s,i]=j.useState("unavailable"),o=j.useRef(n);o.current=n;const l=j.useRef(!1),u=Sr(e,t,a);return j.useEffect(()=>{if(l.current=!1,!e||!r||typeof EventSource>"u"){i("unavailable");return}let c=!1;i("connecting");const f=new EventSource(Pe.runDetailStreamUrl(e),{withCredentials:!0});f.onopen=()=>{c||i("open")};const h=g=>{if(c)return;const m=si(g.data,e,l);m!==null&&(Mr(u,{kind:"loaded",detail:m}),o.current?.(m,u),i("open"))};return f.addEventListener("detail",h),f.onerror=()=>{c||i(f.readyState===EventSource.CLOSED?"closed":"connecting")},()=>{c=!0,f.close()}},[e,r,u]),s}function si(e,r,n){let t;try{t=JSON.parse(e)}catch(a){return Vn(r,n,a),null}try{return Or(t,Pe.runDetailStreamUrl(r))}catch(a){return Vn(r,n,a),null}}function Vn(e,r,n){r.current||(r.current=!0,rn({component:"formula-run-detail-stream",operation:"parse stream frame",message:`${e}: ${tn(n)}`}))}function ii(e,r,n){const t=Sr(e,r,n),[a,s]=j.useState(null),i=j.useRef(0);j.useEffect(()=>()=>{i.current+=1},[]);const{data:o,loading:l,error:u,refresh:c}=Yn(t,()=>{const _=++i.current,N=()=>i.current===_;return oi(e,{onWarming:S=>{N()&&s(S)},keepPolling:N}).finally(()=>{N()&&s(null)})},{onError:_=>{e!==void 0&&ci("load detail",e,_)}}),[f,h]=j.useState(null),g=j.useCallback((_,N)=>h({key:N,detail:_}),[]),m=e!==void 0&&o?.kind!=="unsupported"&&o?.kind!=="not_found",v=ai(e,m,g,r,n),b=f?.key===t?f.detail:null,p=v==="open"||v==="connecting",w=j.useCallback(async()=>{h(null),await c()},[c]);if(e===void 0)return{kind:"idle",refresh:li,streamActive:p};const y=b??(o?.kind==="loaded"?o.detail:null);return y!==null?{kind:"ready",detail:y,refresh:w,refreshState:ui(l,u),streamActive:p}:o?.kind==="unsupported"?{kind:"unsupported",refresh:w,streamActive:p}:o?.kind==="not_found"?{kind:"not_found",refresh:w,streamActive:p}:u!==null?{kind:"failed",error:u,refresh:w,streamActive:p}:{kind:"loading",warming:a,refresh:w,streamActive:p}}async function oi(e,r){if(!e)return{kind:"unrequested"};try{return{kind:"loaded",detail:await ei(e,r)}}catch(n){if(n instanceof Oe&&n.status===422&&n.reason==="not_run_view")return{kind:"unsupported"};if(n instanceof Oe&&n.status===404)return{kind:"not_found"};throw n}}async function li(){}function ui(e,r){return r!==null?{kind:"failed",error:r}:e?{kind:"refreshing"}:{kind:"idle"}}function ci(e,r,n){rn({component:"formula-run-detail",operation:e,message:`${r}: ${tn(n)}`})}function Sr(e,r,n){return["formula-run",e??"missing",r??"default",n??"default"].map(encodeURIComponent).join(":")}function fi(e,r,n,t){const a=gi(e,r,n,t),{data:s,loading:i,error:o,refresh:l,cheapRefresh:u}=Yn(a,()=>He(e,r,n,t),{refreshFetcher:()=>He(e,r,n,t,!0),sseRefreshFetcher:()=>He(e,r,n,t,!1),onError:c=>{e!==void 0&&hi("load diff",e,c)}});return e===void 0||r===void 0?{kind:"idle",refresh:Xn,cheapRefresh:Xn}:s?.kind==="loaded"?{kind:"ready",diff:s.diff,refresh:l,cheapRefresh:u,refreshState:di(i,o)}:o!==null?{kind:"failed",error:o,refresh:l,cheapRefresh:u}:{kind:"loading",refresh:l,cheapRefresh:u}}async function He(e,r,n,t,a){if(!e||r===void 0)return{kind:"unrequested"};const s={};return n!==void 0&&(s.scopeKind=n),t!==void 0&&(s.scopeRef=t),a&&(s.refresh=!0),{kind:"loaded",diff:await Pe.runDiff(e,{executionPath:r},s)}}async function Xn(){}function di(e,r){return r!==null?{kind:"failed",error:r}:e?{kind:"refreshing"}:{kind:"idle"}}function hi(e,r,n){rn({component:"formula-run-detail",operation:e,message:`${r}: ${tn(n)}`})}function gi(e,r,n,t){return["formula-run-diff",e??"missing",mi(r),n??"default",t??"default"].join(":")}function mi(e){return e===void 0?"path:missing":e.kind==="known"?`path:${e.path}`:`path:${e.reason}`}const vi=[wn.bead,wn.session],bi=[];function Li(){const{runId:e}=Ir(),[r]=Rr(),n=Ti(r),t=n.ok?n.scope:void 0,a=n.ok?null:n.error,s=r.get("node"),i=[e??"",t?.scopeKind??"",t?.scopeRef??"",s??""].join("\0"),o=ii(a?void 0:e,t?.scopeKind,t?.scopeRef),l=o.kind==="ready"?o:null,u=l?.detail??null,c=o.kind==="unsupported",f=o.kind==="not_found",h=fi(a||u===null?void 0:e,u?.executionPath,t?.scopeKind,t?.scopeRef),g=o.kind==="loading",m=l!==null&&l.refreshState.kind==="refreshing"||h.kind==="ready"&&h.refreshState.kind==="refreshing",v=u!==null&&h.kind==="loading",b=g||m||v,p=o.kind==="failed"?o.error:l!==null&&l.refreshState.kind==="failed"?l.refreshState.error:null,[w,y]=j.useState("diff"),_=w==="diff",N=o.streamActive;Pr(a?bi:vi,()=>{pi(N,_,o.refresh,h.cheapRefresh)},{matches:I=>{const Y=Zs(I);return u===null?e!==void 0&&(Y.runIds.size===0||Y.runIds.has(e)):u.progress.terminal&&wi(Y)?!1:Xs(Y,{runId:u.runId,rootBeadId:u.rootBeadId})}});const C=j.useRef(h.cheapRefresh);C.current=h.cheapRefresh;const S=j.useRef(_);j.useEffect(()=>{const I=S.current;S.current=_,_&&!I&&C.current()},[_]);const A=j.useCallback(I=>y(I),[]),k=a??p,E=o.kind==="loading"&&o.warming?.reason==="unknown_run",{selectedNodeId:D,selectedNode:B,toggleNode:M}=Ys(u,s,i),R=Ur(u?.rootBeadId??null),[G,L]=j.useState(null),X=$r(),ee=xr(),[Z]=j.useState(()=>Fr(`runs:summary:${ee??"no-city"}`)),ge=j.useMemo(()=>{if(!e)return null;const I=Z&&Z.status!=="error"?Z.data:null;return I==null?null:[...I.lanes,...I.blockedLanes].find(Y=>Y.id===e)??null},[Z,e]),U=u?`${u.progress.visibleNodeCount} nodes. ${Mi(u.progress)}. Local changes are shown for the run execution folder.`:g&&!a||c||f?void 0:"Formula run unavailable.";return d.jsxs("section",{children:[d.jsx(Lr,{title:u?.title??"Formula Run",synopsis:U,meta:d.jsxs(d.Fragment,{children:[d.jsx(Br,{to:"/runs",className:"focus-mark text-label uppercase tracking-wider text-fg-muted hover:text-fg",children:"Runs"}),k&&u&&d.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:k}),u&&d.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:ji(u)}),d.jsx(Gr,{size:"sm",onClick:()=>{kr(o.refresh,h.refresh)},disabled:b||!!a,children:m?"Refreshing":"Refresh"})]})}),b&&!a&&!u?ge?d.jsxs(d.Fragment,{children:[d.jsx(_n,{stages:ge.stages,label:ge.title}),d.jsx("p",{className:"text-body text-fg-muted italic mt-8",children:"Loading run detail."})]}):E?d.jsx("p",{className:"text-body text-fg-muted italic",role:"status",children:"This run may still be being recorded — new work can take a couple of minutes to appear — or it may no longer exist."}):d.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading formula run."}):c?d.jsx("p",{className:"text-body text-fg-muted",role:"status",children:"Detailed step view isn’t available for this run (v1/wisp runs are list-only) — this run appears in the run list only."}):f?d.jsx("p",{className:"text-body text-fg-muted",role:"status",children:"This run’s detail snapshot was not found. It may be a v1/wisp run, a completed run whose snapshot wasn’t retained, or no longer available."}):k&&!u?d.jsx("p",{className:"text-body text-accent",role:"alert",children:k}):l?d.jsxs(d.Fragment,{children:[d.jsx(_i,{detail:l.detail}),d.jsx(_n,{stages:l.detail.stages,label:l.detail.title}),d.jsx(ki,{detail:l.detail}),d.jsxs("div",{className:"mt-8 grid gap-10 lg:grid-cols-[minmax(0,0.95fr)_minmax(22rem,1.05fr)]",children:[d.jsx(et,{detail:l.detail,selectedNodeId:D,onToggleNode:M}),d.jsx(Vs,{diff:h,selectedNode:B,activeTab:w,onActiveTabChange:A})]}),d.jsx(zr,{view:R.view,loading:R.loading,error:R.error,now:X,onOpenBead:L}),d.jsx(Kr,{open:G!==null,onClose:()=>L(null),beadId:G,onOpenBead:L})]}):null]})}async function kr(e,r){await Promise.all([e(),r()])}function pi(e,r,n,t){const a=r?t:yi;return e?a():kr(n,a)}async function yi(){}function wi(e){return e.runIds.size===0&&e.rootBeadIds.size===0}function _i({detail:e}){const r=Si(e.formulaDetail);return d.jsxs("dl",{className:"grid gap-x-8 gap-y-3 sm:grid-cols-2 lg:grid-cols-4",children:[d.jsx(Ni,{formula:e.formula}),r!==null&&d.jsx(ce,{label:"Formula Detail",value:r}),d.jsx(ce,{label:"Root",value:e.rootBeadId}),d.jsx(ce,{label:"Scope",value:`${e.scopeKind}:${e.scopeRef}`}),d.jsx(ce,{label:"Store",value:e.resolvedRootStore||e.rootStoreRef||"unknown"})]})}function ce({label:e,value:r}){return d.jsxs("div",{children:[d.jsx("dt",{className:"text-label uppercase tracking-wider text-fg-faint",children:e}),d.jsx("dd",{className:"text-body text-fg break-all tnum",children:r})]})}const Zn="name inferred from bead title — supervisor did not set gc.formula on this graph.v2 root";function Ni({formula:e}){if(e.kind!=="known")return d.jsx(ce,{label:"Formula",value:"metadata missing"});switch(e.source){case"metadata":return d.jsx(ce,{label:"Formula",value:e.name});case"title_fallback":return d.jsxs("div",{children:[d.jsx("dt",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Formula"}),d.jsxs("dd",{className:"text-body text-warn break-all tnum",title:Zn,"aria-label":`${e.name} (${Zn})`,children:[e.name,d.jsx("span",{className:"ml-2 text-label uppercase tracking-wider text-warn",children:"inferred from bead title"})]})]});default:return e.source}}function ji(e){return e.snapshotEventSeq.kind==="known"?`v${e.snapshotVersion} · seq ${e.snapshotEventSeq.seq}`:`v${e.snapshotVersion}`}function Si(e){return e.kind==="available"?`available for ${e.target}`:e.reason==="missing_formula_metadata"?null:e.reason==="missing_run_target"?`missing run target for ${e.name}`:`${e.failure} for ${e.target}`}function ki({detail:e}){if(e.completeness.kind!=="partial")return null;const r=Ci(e.completeness.reasons);return r.length===0?null:d.jsxs("p",{className:"mt-5 text-label uppercase tracking-wider text-warn",role:"status",children:["Partial run data: ",Ei(r),"."]})}function Ci(e){return e.filter(r=>!Ai(r))}function Ai(e){switch(e){case"formula_detail_missing_formula_metadata":case"formula_detail_missing_run_target":case"formula_detail_fetch_failed":return!0;case"supervisor_snapshot_partial":case"runtime_bead_read_failed":case"session_list_failed":return!1}}function Ei(e){return e.map(Di).join(", ")}function Di(e){switch(e){case"supervisor_snapshot_partial":return"supervisor snapshot is partial";case"runtime_bead_read_failed":return"runtime bead refresh failed";case"session_list_failed":return"session list failed";case"formula_detail_missing_formula_metadata":return"formula metadata is missing";case"formula_detail_missing_run_target":return"formula run target is missing";case"formula_detail_fetch_failed":return"formula detail fetch failed"}}function Ti(e){const r=e.getAll("scope_kind"),n=e.getAll("scope_ref");if(r.length>1||n.length>1)return{ok:!1,error:"Invalid run scope query."};const t=r[0],a=n[0];return t===void 0&&a===void 0?{ok:!0}:t===void 0||a===void 0?{ok:!1,error:"Invalid run scope query."}:t!=="city"&&t!=="rig"?{ok:!1,error:"Invalid run scope query."}:Vr.test(a)?{ok:!0,scope:{scopeKind:t,scopeRef:a}}:{ok:!1,error:"Invalid run scope query."}}function Mi(e){const r=[re(e,["active","running"],"running"),re(e,["completed","done"],"done"),re(e,"ready","ready"),re(e,"blocked","blocked"),re(e,"failed","failed"),re(e,"skipped","skipped"),re(e,"pending","pending")].filter(n=>n!==null);return r.length>0?r.join(", "):"No node status yet"}function re(e,r,n){const a=(typeof r=="string"?[r]:r).reduce((s,i)=>s+(e.statusCounts[i]??0),0);return a>0?`${a} ${n}`:null}export{Li as FormulaRunDetailPage,pi as runDetailNudgeRefresh}; diff --git a/internal/api/dashboardspa/dist/assets/Health-DWOkvU0J.js b/internal/api/dashboardspa/dist/assets/Health-C5mLLJQ2.js similarity index 98% rename from internal/api/dashboardspa/dist/assets/Health-DWOkvU0J.js rename to internal/api/dashboardspa/dist/assets/Health-C5mLLJQ2.js index 96dc40ec5a..29f43835b3 100644 --- a/internal/api/dashboardspa/dist/assets/Health-DWOkvU0J.js +++ b/internal/api/dashboardspa/dist/assets/Health-C5mLLJQ2.js @@ -1 +1 @@ -import{a as Y,b as v,r as Z,j as t,B as ee,X as y,z as E,S as V,H as F,ab as te}from"./index-BFDP6Xwd.js";import{p as $,d as ae}from"./routeHighlight-B30gQO2o.js";import{P as se}from"./PageHeader-5RHLpIfH.js";import{u as le}from"./useVisibleRefresh-Bxd6CPUo.js";import{a as x}from"./format-fte2CeYD.js";import{a as ne}from"./time-D9v0saHV.js";const re=2500;function Ee(){const e=Y(),a=F(),s=v("health:system",ye),o=v(`health:supervisor:${a??"no-city"}`,_e),r=v(`health:status:${a??"no-city"}`,we),c=v("health:local-tools",Ne),b=v(`health:dolt-noms-trend:${a??"no-city"}`,Se),p=v(`health:rig-store:${a??"no-city"}`,ke),d=s.refresh,_=o.refresh,w=r.refresh,N=c.refresh,C=b.refresh,L=p.refresh,W=s.loading||o.loading||r.loading||c.loading||b.loading||p.loading,T=[s.error,o.error,r.error,c.error,b.error,p.error].filter(J=>J!==null).join("; ")||null,D=Z.useCallback(async()=>{await Promise.all([d(),_(),w(),N(),C(),L()])},[C,N,L,_,w,d]),m=s.data??null,n=m?.status==="available"?m.data:null,S=m?.status==="unavailable"?m.error:null,i=o.data??null,k=r.data??null,U=c.data??null,u=b.data??null,f=p.data??null,B=f?pe(f):void 0,R=m!==null||i!==null||k!==null||U!==null||u!==null||f!==null,M=n?He(n):void 0,X=$(e,"health",["health:supervisor-"]),q=$(e,"health",["health:load-","health:memory-"]),Q=$(e,"health",["health:dashboard-"]),G=$(e,"health",["health:dolt-noms-"]);return le(D,3e4),t.jsxs("section",{children:[t.jsx(se,{title:"Health",synopsis:R?$e(n,i):"Reading state from the supervisor.",meta:t.jsxs(t.Fragment,{children:[T&&t.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:T}),t.jsx(ee,{size:"sm",onClick:()=>{D()},children:W&&!R?"Loading":"Refresh"})]})}),R?t.jsxs("div",{className:"space-y-12",children:[t.jsx(h,{title:"Supervisor",attention:X,...i?{status:Re(i)}:{},children:i===null?t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading supervisor state."}):i.status==="available"?t.jsxs(g,{children:[i.data.city!==void 0?t.jsx(l,{label:"City",value:i.data.city}):t.jsx(l,{label:"City",value:"not reported by supervisor",tone:"warn"}),i.data.version!==void 0?t.jsx(l,{label:"Version",value:i.data.version}):t.jsx(l,{label:"Version",value:"not reported by supervisor",tone:"warn"}),t.jsx(l,{label:"Uptime",value:j(i.data.uptime_sec)}),t.jsx(l,{label:"Status",value:i.data.status})]}):t.jsx("p",{className:"text-body text-accent",children:"Supervisor not reachable. The dashboard shell stays up; live data is stale."})}),t.jsx(h,{title:"Host",attention:q,...M?{status:M}:{},children:m===null?t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading dashboard host health."}):n===null?t.jsxs("p",{className:"text-body text-accent",children:["Dashboard host health unavailable",S?`: ${S}`:"","."]}):t.jsxs(g,{children:[t.jsx(l,{label:"CPUs",value:n.host.cpu_count.toString()}),t.jsx(l,{label:"Load (1m, 5m, 15m)",value:`${n.host.load_avg_1.toFixed(2)}, ${n.host.load_avg_5.toFixed(2)}, ${n.host.load_avg_15.toFixed(2)}`,...n.host.load_avg_1>n.host.cpu_count?{tone:"warn"}:{}}),t.jsx(l,{label:"Memory free",value:`${x(n.host.free_mem_bytes)} of ${x(n.host.total_mem_bytes)}`,...n.host.free_mem_bytes/n.host.total_mem_bytes<.1?{tone:"warn"}:{}}),t.jsx(l,{label:"Host uptime",value:j(n.host.uptime_sec)})]})}),t.jsx(h,{title:"Admin process",attention:Q,children:m===null?t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading dashboard process health."}):n===null?t.jsxs("p",{className:"text-body text-accent",children:["Dashboard process health unavailable",S?`: ${S}`:"","."]}):t.jsxs(g,{children:[t.jsx(l,{label:"PID",value:n.admin.pid.toString()}),t.jsx(l,{label:"Uptime",value:j(n.admin.uptime_sec)}),t.jsx(l,{label:"RSS",value:x(n.admin.rss_bytes)}),t.jsx(l,{label:"Heap used",value:x(n.admin.heap_used_bytes)}),t.jsx(l,{label:"Node",value:n.admin.node_version})]})}),t.jsx(h,{title:"Tool versions",children:t.jsx(oe,{state:U})}),t.jsx(h,{title:"Diagnostics",children:t.jsxs("div",{className:"space-y-8",children:[t.jsx(ce,{usage:K(k)}),t.jsx(ue,{usage:Ce(k)})]})}),t.jsx(h,{title:"Bead stores · per rig",meta:be(f),...B?{status:B}:{},children:t.jsx(de,{report:f})}),t.jsx(h,{title:"Store thresholds",children:t.jsx(ve,{comparison:Le(k)})}),t.jsx(h,{title:"Dolt-noms · 24 h",attention:G,meta:u&&u.samples.length>0?`${u.samples.length} samples`:void 0,children:u===null?t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading."}):u.available?u.samples.length===0?t.jsx("p",{className:"text-body text-fg-muted italic",children:"No samples yet. Backend just started; next sample in ten minutes or less."}):t.jsx(ge,{samples:u.samples}):t.jsxs("p",{className:"text-body text-fg-muted italic",children:["Dolt-noms metric unavailable: ",je(u.reason),"."]})})]}):t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading."})]})}function h({title:e,status:a,meta:s,attention:o,children:r}){return t.jsxs("section",{...ae(o??null),children:[t.jsxs("header",{className:"flex items-baseline justify-between gap-4 mb-4 pb-2 border-b border-rule",children:[t.jsx("h2",{className:"text-headline font-semibold text-fg",children:e}),t.jsxs("div",{className:"flex items-baseline gap-4",children:[s&&t.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:s}),a&&t.jsx(V,{tone:a.tone,label:a.label})]})]}),r]})}function g({children:e}){return t.jsx("dl",{className:"grid grid-cols-[max-content_1fr] gap-x-8 gap-y-3 max-w-prose",children:e})}function l({label:e,value:a,tone:s}){const o=s==="warn"?"text-warn":s==="stuck"?"text-accent":"text-fg";return t.jsxs(t.Fragment,{children:[t.jsx("dt",{className:"text-body text-fg-muted",children:e}),t.jsx("dd",{className:`text-body tnum font-medium ${o}`,children:a})]})}function oe({state:e}){if(e===null)return t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading tool versions."});if(e.status==="unavailable")return t.jsxs("p",{className:"text-body text-fg-muted italic",children:["Tool versions unavailable: ",e.error,"."]});const a=[{label:"gc",tool:e.data.gc},{label:"bd",tool:e.data.beads},{label:"dolt",tool:e.data.dolt}];return t.jsxs("div",{className:"grid grid-cols-[1fr_max-content] gap-x-8 gap-y-3 max-w-prose",children:[t.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Tool"}),t.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted text-right",children:"Installed"}),a.map(s=>t.jsx(ie,{label:s.label,tool:s.tool},s.label))]})}function ie({label:e,tool:a}){return t.jsxs("div",{className:"contents","data-tool-version-row":e,children:[t.jsx("div",{className:"text-body text-fg",children:e}),t.jsx("div",{className:"text-right",children:a.status==="available"?t.jsx("span",{className:"text-body tnum font-medium text-fg",children:a.version}):t.jsxs("div",{className:"space-y-1",children:[t.jsx("div",{className:"text-body tnum font-medium text-warn",children:"unavailable"}),t.jsx("div",{className:"text-label text-fg-muted normal-case",children:a.reason})]})})]})}function ce({usage:e}){if(e.status==="unavailable")return t.jsx(O,{heading:"Dolt usage",reason:e.reason});const a=e.value;return t.jsxs("div",{className:"space-y-2",children:[t.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Dolt usage"}),e.stale!==void 0&&t.jsx(H,{message:e.stale}),t.jsxs(g,{children:[t.jsx(l,{label:"On-disk size",value:x(Te(a.size_bytes))}),t.jsx(l,{label:"Live rows",value:a.live_rows.toLocaleString()}),t.jsx(l,{label:"MB per row",value:a.ratio_mb_per_row.toString()}),t.jsx(l,{label:"Last maintenance",value:a.last_gc_status??"not reported",...a.last_gc_status!==void 0&&a.last_gc_status!=="success"?{tone:"warn"}:{}}),a.last_gc_at!==void 0&&t.jsx(l,{label:"Last maintenance at",value:ne(a.last_gc_at)}),t.jsx(l,{label:"Store path",value:a.path})]})]})}function ue({usage:e}){if(e.status==="unavailable")return t.jsx(O,{heading:"Beads usage",reason:e.reason});const a=e.value;return t.jsxs("div",{className:"space-y-2",children:[t.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Beads usage"}),e.stale!==void 0&&t.jsx(H,{message:e.stale}),t.jsxs(g,{children:[t.jsx(l,{label:"Open",value:a.open.toString()}),t.jsx(l,{label:"Ready",value:a.ready.toString()}),t.jsx(l,{label:"In progress",value:a.in_progress.toString()})]})]})}function de({report:e}){if(e===null)return t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading per-rig store health."});if(!e.available&&e.rigs.length===0)return t.jsxs("p",{className:"text-body text-fg-muted italic",children:["Per-rig store health unavailable: ",P(e.reason),"."]});const a=[...e.rigs].sort((s,o)=>A(o.rollup)-A(s.rollup));return t.jsxs("div",{className:"space-y-6 max-w-prose",children:[!e.available&&t.jsxs("p",{className:"text-body text-warn italic",children:["Showing the last sample; refresh failed: ",P(e.reason),"."]}),a.map(s=>t.jsx(he,{rig:s},s.rig))]})}function he({rig:e}){const a=xe(e);return t.jsxs("div",{className:"space-y-2 border-b border-rule pb-4 last:border-b-0",children:[t.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[t.jsx("span",{className:"text-body font-medium text-fg",children:e.rig}),t.jsx(V,{tone:a.tone,label:a.label})]}),t.jsxs("dl",{className:"grid grid-cols-[max-content_1fr] gap-x-6 gap-y-1",children:[t.jsx(l,{label:"Dolt server",value:me(e),...e.doltConnected===!1?{tone:"stuck"}:{}}),e.issueCount!==null&&t.jsx(l,{label:"Live issues",value:e.issueCount.toLocaleString()})]}),e.problems.length>0&&t.jsx("ul",{className:"space-y-1",children:e.problems.map(s=>t.jsxs("li",{className:`text-label ${s.status==="error"?"text-accent":"text-warn"}`,children:[s.name,": ",s.message]},`${s.category}/${s.name}`))}),e.note!==void 0&&t.jsx("p",{className:"text-label text-fg-muted italic",children:e.note})]})}function me(e){const a=e.doltEndpoint??"no endpoint reported";return e.doltConnected===!0?`up · ${a}`:e.doltConnected===!1?`DOWN · ${a}`:`unknown · ${a}`}function xe(e){switch(e.rollup){case"ok":return{tone:"ok",label:"healthy"};case"warn":return{tone:"warn",label:"warnings"};case"down":return e.reachable?e.doltConnected===!1?{tone:"stuck",label:"dolt down"}:{tone:"stuck",label:"errors"}:{tone:"stuck",label:"unreachable"}}}function A(e){return e==="down"?2:e==="warn"?1:0}function be(e){if(e===null||e.rigs.length===0)return;const a={ok:0,warn:0,down:0};for(const s of e.rigs)a[s.rollup]+=1;return`${a.ok} ok · ${a.warn} warn · ${a.down} down`}function pe(e){if(e.rigs.some(a=>a.rollup==="down"))return{tone:"stuck",label:"attention"};if(e.rigs.some(a=>a.rollup==="warn"))return{tone:"warn",label:"warnings"};if(e.rigs.length>0)return{tone:"ok",label:"healthy"}}function P(e){switch(e){case"not_sampled_yet":return"backend just started; first sample is in flight";case"rig_list_failed":return"the supervisor rig list could not be read";case"fetch_failed":return"the dashboard backend could not be reached"}}function ve({comparison:e}){return e.status==="unavailable"?t.jsxs("p",{className:"text-body text-fg-muted italic",children:["Comparison unavailable: ",e.reason,"."]}):t.jsxs("div",{className:"space-y-2",children:[e.stale!==void 0&&t.jsx(H,{message:e.stale}),t.jsxs("div",{className:"grid grid-cols-[1fr_max-content_max-content] gap-x-8 gap-y-3 max-w-prose",children:[t.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Setting"}),t.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted text-right",children:"Recommended"}),t.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted text-right",children:"Loaded"}),e.value.map(a=>t.jsx(fe,{row:a},a.label))]})]})}function fe({row:e}){const a=e.withinRecommendation?"text-fg":"text-warn";return t.jsxs("div",{className:`contents ${a}`,"data-comparison-row":e.label,children:[t.jsxs("div",{className:`text-body ${a}`,children:[e.label,!e.withinRecommendation&&t.jsx("span",{className:"text-label uppercase tracking-wider text-warn",children:" · over"})]}),t.jsx("div",{className:"text-body tnum text-fg-muted text-right",children:e.recommended}),t.jsx("div",{className:`text-body tnum font-medium text-right ${a}`,children:e.loaded})]})}function O({heading:e,reason:a}){return t.jsxs("div",{className:"space-y-2",children:[t.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-muted",children:e}),t.jsxs("p",{className:"text-body text-fg-muted italic",children:["Unavailable: ",a,"."]})]})}function H({message:e}){return t.jsx("p",{className:"text-body text-warn italic",children:e})}function ge({samples:e}){if(e.length===0)return null;const a=Math.max(...e.map(d=>d.bytes)),s=Math.min(...e.map(d=>d.bytes)),o=a-s||1,r=600,c=60,b=e.length>1?r/(e.length-1):r,p=e.map((d,_)=>{const w=_*b,N=c-(d.bytes-s)/o*c;return`${w.toFixed(1)},${N.toFixed(1)}`}).join(" ");return t.jsxs("div",{className:"space-y-3 max-w-prose",children:[t.jsx("svg",{viewBox:`0 0 ${r} ${c}`,preserveAspectRatio:"none",className:"w-full h-16","aria-label":"24 hour dolt-noms size trend",children:t.jsx("polyline",{fill:"none",stroke:"currentColor",strokeWidth:"1",className:"text-accent",points:p})}),t.jsxs("div",{className:"flex items-baseline justify-between text-label uppercase tracking-wider text-fg-muted tnum",children:[t.jsxs("span",{children:["min ",x(s)]}),t.jsxs("span",{children:["max ",x(a)]})]})]})}function je(e){switch(e){case"store_health_absent":return"supervisor is not reporting store_health; samples resume when it recovers";case"sample_failed":return"latest supervisor status read failed; check the backend log"}}async function ye(){try{return{status:"available",data:await y.systemHealth()}}catch(e){return{status:"unavailable",error:E(e,"dashboard host health unavailable")}}}async function _e(){const e=F();if(e===null)throw new Error("Health page loaded before an active city was resolved");try{return{status:"available",data:await te(re).cityHealth(e)}}catch{return{status:"unavailable",error:"supervisor health unavailable"}}}function z(e){switch(e){case"not_sampled_yet":return"supervisor status sample is warming up; data appears after the next backend sample";case"status_read_failed":return"latest supervisor status read failed; check the backend log"}}function I(e){return`Showing the last sample; refresh failed: ${z(e)}.`}async function we(){try{const e=await y.supervisorStatus();return e.available?{status:"available",data:e.status,staleReason:null}:e.status!==null?{status:"available",data:e.status,staleReason:e.reason}:{status:"unavailable",error:z(e.reason)}}catch(e){return{status:"unavailable",error:E(e,"supervisor status unavailable")}}}async function Ne(){try{return{status:"available",data:await y.localToolVersions()}}catch{return{status:"unavailable",error:"local tool versions unavailable"}}}async function Se(){try{return await y.doltTrend()}catch{return{available:!1,reason:"sample_failed",samples:[]}}}async function ke(){try{return await y.rigStoreHealth()}catch{return{available:!1,reason:"fetch_failed",rigs:[]}}}function $e(e,a){const s=[];if(a===null)s.push("Supervisor state still loading.");else if(a.status==="available"){const r=a.data,c=r.status==="ok"?"healthy":r.status;r.city!==void 0?s.push(`Supervisor ${c} on ${r.city}, uptime ${j(r.uptime_sec)}.`):s.push(`Supervisor ${c}, uptime ${j(r.uptime_sec)}.`)}else s.push("Supervisor unreachable.");if(e===null)return s.push("Host health unavailable."),s.join(" ");const o=Math.round(100*(1-e.host.free_mem_bytes/e.host.total_mem_bytes));return s.push(`Memory at ${o}%; ${e.host.cpu_count} CPUs averaging ${e.host.load_avg_1.toFixed(2)} load.`),s.join(" ")}function Re(e){return e.status==="unavailable"?{tone:"stuck",label:"offline"}:e.data.status==="ok"?{tone:"ok",label:"healthy"}:{tone:"warn",label:e.data.status}}function He(e){const a=e.host.free_mem_bytes/e.host.total_mem_bytes;if(a<.05)return{tone:"stuck",label:"memory critical"};if(a<.1)return{tone:"warn",label:"memory low"};if(e.host.load_avg_1>e.host.cpu_count*1.5)return{tone:"warn",label:"load high"}}function K(e){if(e===null)return{status:"unavailable",reason:"supervisor status still loading"};if(e.status==="unavailable")return{status:"unavailable",reason:e.error};const a=e.data.store_health;return a===void 0?{status:"unavailable",reason:"supervisor did not report store_health"}:{status:"available",value:a,source:"supervisor status.store_health",...e.staleReason!==null?{stale:I(e.staleReason)}:{}}}function Ce(e){return e===null?{status:"unavailable",reason:"supervisor status still loading"}:e.status==="unavailable"?{status:"unavailable",reason:e.error}:{status:"available",value:e.data.work,source:"supervisor status.work",...e.staleReason!==null?{stale:I(e.staleReason)}:{}}}function Le(e){const a=K(e);if(a.status==="unavailable")return{status:"unavailable",reason:a.reason};const s=a.value;return{status:"available",source:"supervisor status.store_health (threshold vs actual)",...a.stale!==void 0?{stale:a.stale}:{},value:[{label:"Dolt MB-per-row ratio",recommended:`<= ${s.threshold_mb_per_row}`,loaded:String(s.ratio_mb_per_row),withinRecommendation:!s.warning}]}}function Te(e){return typeof e=="bigint"?Number(e):e}function j(e){if(e<60)return`${e}s`;if(e<3600)return`${Math.round(e/60)}m`;if(e<86400)return`${Math.round(e/3600)}h`;const a=Math.floor(e/86400),s=Math.round(e%86400/3600);return s>0?`${a}d ${s}h`:`${a}d`}export{Ee as HealthPage}; +import{a as Y,b as v,r as Z,j as t,B as ee,X as y,z as E,S as V,H as F,ab as te}from"./index-C20tCZFz.js";import{p as $,d as ae}from"./routeHighlight-B30gQO2o.js";import{P as se}from"./PageHeader-D_D-jYn1.js";import{u as le}from"./useVisibleRefresh-D_HCcAAw.js";import{a as x}from"./format-fte2CeYD.js";import{a as ne}from"./time-D9v0saHV.js";const re=2500;function Ee(){const e=Y(),a=F(),s=v("health:system",ye),o=v(`health:supervisor:${a??"no-city"}`,_e),r=v(`health:status:${a??"no-city"}`,we),c=v("health:local-tools",Ne),b=v(`health:dolt-noms-trend:${a??"no-city"}`,Se),p=v(`health:rig-store:${a??"no-city"}`,ke),d=s.refresh,_=o.refresh,w=r.refresh,N=c.refresh,C=b.refresh,L=p.refresh,W=s.loading||o.loading||r.loading||c.loading||b.loading||p.loading,T=[s.error,o.error,r.error,c.error,b.error,p.error].filter(J=>J!==null).join("; ")||null,D=Z.useCallback(async()=>{await Promise.all([d(),_(),w(),N(),C(),L()])},[C,N,L,_,w,d]),m=s.data??null,n=m?.status==="available"?m.data:null,S=m?.status==="unavailable"?m.error:null,i=o.data??null,k=r.data??null,U=c.data??null,u=b.data??null,f=p.data??null,B=f?pe(f):void 0,R=m!==null||i!==null||k!==null||U!==null||u!==null||f!==null,M=n?He(n):void 0,X=$(e,"health",["health:supervisor-"]),q=$(e,"health",["health:load-","health:memory-"]),Q=$(e,"health",["health:dashboard-"]),G=$(e,"health",["health:dolt-noms-"]);return le(D,3e4),t.jsxs("section",{children:[t.jsx(se,{title:"Health",synopsis:R?$e(n,i):"Reading state from the supervisor.",meta:t.jsxs(t.Fragment,{children:[T&&t.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:T}),t.jsx(ee,{size:"sm",onClick:()=>{D()},children:W&&!R?"Loading":"Refresh"})]})}),R?t.jsxs("div",{className:"space-y-12",children:[t.jsx(h,{title:"Supervisor",attention:X,...i?{status:Re(i)}:{},children:i===null?t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading supervisor state."}):i.status==="available"?t.jsxs(g,{children:[i.data.city!==void 0?t.jsx(l,{label:"City",value:i.data.city}):t.jsx(l,{label:"City",value:"not reported by supervisor",tone:"warn"}),i.data.version!==void 0?t.jsx(l,{label:"Version",value:i.data.version}):t.jsx(l,{label:"Version",value:"not reported by supervisor",tone:"warn"}),t.jsx(l,{label:"Uptime",value:j(i.data.uptime_sec)}),t.jsx(l,{label:"Status",value:i.data.status})]}):t.jsx("p",{className:"text-body text-accent",children:"Supervisor not reachable. The dashboard shell stays up; live data is stale."})}),t.jsx(h,{title:"Host",attention:q,...M?{status:M}:{},children:m===null?t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading dashboard host health."}):n===null?t.jsxs("p",{className:"text-body text-accent",children:["Dashboard host health unavailable",S?`: ${S}`:"","."]}):t.jsxs(g,{children:[t.jsx(l,{label:"CPUs",value:n.host.cpu_count.toString()}),t.jsx(l,{label:"Load (1m, 5m, 15m)",value:`${n.host.load_avg_1.toFixed(2)}, ${n.host.load_avg_5.toFixed(2)}, ${n.host.load_avg_15.toFixed(2)}`,...n.host.load_avg_1>n.host.cpu_count?{tone:"warn"}:{}}),t.jsx(l,{label:"Memory free",value:`${x(n.host.free_mem_bytes)} of ${x(n.host.total_mem_bytes)}`,...n.host.free_mem_bytes/n.host.total_mem_bytes<.1?{tone:"warn"}:{}}),t.jsx(l,{label:"Host uptime",value:j(n.host.uptime_sec)})]})}),t.jsx(h,{title:"Admin process",attention:Q,children:m===null?t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading dashboard process health."}):n===null?t.jsxs("p",{className:"text-body text-accent",children:["Dashboard process health unavailable",S?`: ${S}`:"","."]}):t.jsxs(g,{children:[t.jsx(l,{label:"PID",value:n.admin.pid.toString()}),t.jsx(l,{label:"Uptime",value:j(n.admin.uptime_sec)}),t.jsx(l,{label:"RSS",value:x(n.admin.rss_bytes)}),t.jsx(l,{label:"Heap used",value:x(n.admin.heap_used_bytes)}),t.jsx(l,{label:"Node",value:n.admin.node_version})]})}),t.jsx(h,{title:"Tool versions",children:t.jsx(oe,{state:U})}),t.jsx(h,{title:"Diagnostics",children:t.jsxs("div",{className:"space-y-8",children:[t.jsx(ce,{usage:K(k)}),t.jsx(ue,{usage:Ce(k)})]})}),t.jsx(h,{title:"Bead stores · per rig",meta:be(f),...B?{status:B}:{},children:t.jsx(de,{report:f})}),t.jsx(h,{title:"Store thresholds",children:t.jsx(ve,{comparison:Le(k)})}),t.jsx(h,{title:"Dolt-noms · 24 h",attention:G,meta:u&&u.samples.length>0?`${u.samples.length} samples`:void 0,children:u===null?t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading."}):u.available?u.samples.length===0?t.jsx("p",{className:"text-body text-fg-muted italic",children:"No samples yet. Backend just started; next sample in ten minutes or less."}):t.jsx(ge,{samples:u.samples}):t.jsxs("p",{className:"text-body text-fg-muted italic",children:["Dolt-noms metric unavailable: ",je(u.reason),"."]})})]}):t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading."})]})}function h({title:e,status:a,meta:s,attention:o,children:r}){return t.jsxs("section",{...ae(o??null),children:[t.jsxs("header",{className:"flex items-baseline justify-between gap-4 mb-4 pb-2 border-b border-rule",children:[t.jsx("h2",{className:"text-headline font-semibold text-fg",children:e}),t.jsxs("div",{className:"flex items-baseline gap-4",children:[s&&t.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:s}),a&&t.jsx(V,{tone:a.tone,label:a.label})]})]}),r]})}function g({children:e}){return t.jsx("dl",{className:"grid grid-cols-[max-content_1fr] gap-x-8 gap-y-3 max-w-prose",children:e})}function l({label:e,value:a,tone:s}){const o=s==="warn"?"text-warn":s==="stuck"?"text-accent":"text-fg";return t.jsxs(t.Fragment,{children:[t.jsx("dt",{className:"text-body text-fg-muted",children:e}),t.jsx("dd",{className:`text-body tnum font-medium ${o}`,children:a})]})}function oe({state:e}){if(e===null)return t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading tool versions."});if(e.status==="unavailable")return t.jsxs("p",{className:"text-body text-fg-muted italic",children:["Tool versions unavailable: ",e.error,"."]});const a=[{label:"gc",tool:e.data.gc},{label:"bd",tool:e.data.beads},{label:"dolt",tool:e.data.dolt}];return t.jsxs("div",{className:"grid grid-cols-[1fr_max-content] gap-x-8 gap-y-3 max-w-prose",children:[t.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Tool"}),t.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted text-right",children:"Installed"}),a.map(s=>t.jsx(ie,{label:s.label,tool:s.tool},s.label))]})}function ie({label:e,tool:a}){return t.jsxs("div",{className:"contents","data-tool-version-row":e,children:[t.jsx("div",{className:"text-body text-fg",children:e}),t.jsx("div",{className:"text-right",children:a.status==="available"?t.jsx("span",{className:"text-body tnum font-medium text-fg",children:a.version}):t.jsxs("div",{className:"space-y-1",children:[t.jsx("div",{className:"text-body tnum font-medium text-warn",children:"unavailable"}),t.jsx("div",{className:"text-label text-fg-muted normal-case",children:a.reason})]})})]})}function ce({usage:e}){if(e.status==="unavailable")return t.jsx(O,{heading:"Dolt usage",reason:e.reason});const a=e.value;return t.jsxs("div",{className:"space-y-2",children:[t.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Dolt usage"}),e.stale!==void 0&&t.jsx(H,{message:e.stale}),t.jsxs(g,{children:[t.jsx(l,{label:"On-disk size",value:x(Te(a.size_bytes))}),t.jsx(l,{label:"Live rows",value:a.live_rows.toLocaleString()}),t.jsx(l,{label:"MB per row",value:a.ratio_mb_per_row.toString()}),t.jsx(l,{label:"Last maintenance",value:a.last_gc_status??"not reported",...a.last_gc_status!==void 0&&a.last_gc_status!=="success"?{tone:"warn"}:{}}),a.last_gc_at!==void 0&&t.jsx(l,{label:"Last maintenance at",value:ne(a.last_gc_at)}),t.jsx(l,{label:"Store path",value:a.path})]})]})}function ue({usage:e}){if(e.status==="unavailable")return t.jsx(O,{heading:"Beads usage",reason:e.reason});const a=e.value;return t.jsxs("div",{className:"space-y-2",children:[t.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Beads usage"}),e.stale!==void 0&&t.jsx(H,{message:e.stale}),t.jsxs(g,{children:[t.jsx(l,{label:"Open",value:a.open.toString()}),t.jsx(l,{label:"Ready",value:a.ready.toString()}),t.jsx(l,{label:"In progress",value:a.in_progress.toString()})]})]})}function de({report:e}){if(e===null)return t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading per-rig store health."});if(!e.available&&e.rigs.length===0)return t.jsxs("p",{className:"text-body text-fg-muted italic",children:["Per-rig store health unavailable: ",P(e.reason),"."]});const a=[...e.rigs].sort((s,o)=>A(o.rollup)-A(s.rollup));return t.jsxs("div",{className:"space-y-6 max-w-prose",children:[!e.available&&t.jsxs("p",{className:"text-body text-warn italic",children:["Showing the last sample; refresh failed: ",P(e.reason),"."]}),a.map(s=>t.jsx(he,{rig:s},s.rig))]})}function he({rig:e}){const a=xe(e);return t.jsxs("div",{className:"space-y-2 border-b border-rule pb-4 last:border-b-0",children:[t.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[t.jsx("span",{className:"text-body font-medium text-fg",children:e.rig}),t.jsx(V,{tone:a.tone,label:a.label})]}),t.jsxs("dl",{className:"grid grid-cols-[max-content_1fr] gap-x-6 gap-y-1",children:[t.jsx(l,{label:"Dolt server",value:me(e),...e.doltConnected===!1?{tone:"stuck"}:{}}),e.issueCount!==null&&t.jsx(l,{label:"Live issues",value:e.issueCount.toLocaleString()})]}),e.problems.length>0&&t.jsx("ul",{className:"space-y-1",children:e.problems.map(s=>t.jsxs("li",{className:`text-label ${s.status==="error"?"text-accent":"text-warn"}`,children:[s.name,": ",s.message]},`${s.category}/${s.name}`))}),e.note!==void 0&&t.jsx("p",{className:"text-label text-fg-muted italic",children:e.note})]})}function me(e){const a=e.doltEndpoint??"no endpoint reported";return e.doltConnected===!0?`up · ${a}`:e.doltConnected===!1?`DOWN · ${a}`:`unknown · ${a}`}function xe(e){switch(e.rollup){case"ok":return{tone:"ok",label:"healthy"};case"warn":return{tone:"warn",label:"warnings"};case"down":return e.reachable?e.doltConnected===!1?{tone:"stuck",label:"dolt down"}:{tone:"stuck",label:"errors"}:{tone:"stuck",label:"unreachable"}}}function A(e){return e==="down"?2:e==="warn"?1:0}function be(e){if(e===null||e.rigs.length===0)return;const a={ok:0,warn:0,down:0};for(const s of e.rigs)a[s.rollup]+=1;return`${a.ok} ok · ${a.warn} warn · ${a.down} down`}function pe(e){if(e.rigs.some(a=>a.rollup==="down"))return{tone:"stuck",label:"attention"};if(e.rigs.some(a=>a.rollup==="warn"))return{tone:"warn",label:"warnings"};if(e.rigs.length>0)return{tone:"ok",label:"healthy"}}function P(e){switch(e){case"not_sampled_yet":return"backend just started; first sample is in flight";case"rig_list_failed":return"the supervisor rig list could not be read";case"fetch_failed":return"the dashboard backend could not be reached"}}function ve({comparison:e}){return e.status==="unavailable"?t.jsxs("p",{className:"text-body text-fg-muted italic",children:["Comparison unavailable: ",e.reason,"."]}):t.jsxs("div",{className:"space-y-2",children:[e.stale!==void 0&&t.jsx(H,{message:e.stale}),t.jsxs("div",{className:"grid grid-cols-[1fr_max-content_max-content] gap-x-8 gap-y-3 max-w-prose",children:[t.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Setting"}),t.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted text-right",children:"Recommended"}),t.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted text-right",children:"Loaded"}),e.value.map(a=>t.jsx(fe,{row:a},a.label))]})]})}function fe({row:e}){const a=e.withinRecommendation?"text-fg":"text-warn";return t.jsxs("div",{className:`contents ${a}`,"data-comparison-row":e.label,children:[t.jsxs("div",{className:`text-body ${a}`,children:[e.label,!e.withinRecommendation&&t.jsx("span",{className:"text-label uppercase tracking-wider text-warn",children:" · over"})]}),t.jsx("div",{className:"text-body tnum text-fg-muted text-right",children:e.recommended}),t.jsx("div",{className:`text-body tnum font-medium text-right ${a}`,children:e.loaded})]})}function O({heading:e,reason:a}){return t.jsxs("div",{className:"space-y-2",children:[t.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-muted",children:e}),t.jsxs("p",{className:"text-body text-fg-muted italic",children:["Unavailable: ",a,"."]})]})}function H({message:e}){return t.jsx("p",{className:"text-body text-warn italic",children:e})}function ge({samples:e}){if(e.length===0)return null;const a=Math.max(...e.map(d=>d.bytes)),s=Math.min(...e.map(d=>d.bytes)),o=a-s||1,r=600,c=60,b=e.length>1?r/(e.length-1):r,p=e.map((d,_)=>{const w=_*b,N=c-(d.bytes-s)/o*c;return`${w.toFixed(1)},${N.toFixed(1)}`}).join(" ");return t.jsxs("div",{className:"space-y-3 max-w-prose",children:[t.jsx("svg",{viewBox:`0 0 ${r} ${c}`,preserveAspectRatio:"none",className:"w-full h-16","aria-label":"24 hour dolt-noms size trend",children:t.jsx("polyline",{fill:"none",stroke:"currentColor",strokeWidth:"1",className:"text-accent",points:p})}),t.jsxs("div",{className:"flex items-baseline justify-between text-label uppercase tracking-wider text-fg-muted tnum",children:[t.jsxs("span",{children:["min ",x(s)]}),t.jsxs("span",{children:["max ",x(a)]})]})]})}function je(e){switch(e){case"store_health_absent":return"supervisor is not reporting store_health; samples resume when it recovers";case"sample_failed":return"latest supervisor status read failed; check the backend log"}}async function ye(){try{return{status:"available",data:await y.systemHealth()}}catch(e){return{status:"unavailable",error:E(e,"dashboard host health unavailable")}}}async function _e(){const e=F();if(e===null)throw new Error("Health page loaded before an active city was resolved");try{return{status:"available",data:await te(re).cityHealth(e)}}catch{return{status:"unavailable",error:"supervisor health unavailable"}}}function z(e){switch(e){case"not_sampled_yet":return"supervisor status sample is warming up; data appears after the next backend sample";case"status_read_failed":return"latest supervisor status read failed; check the backend log"}}function I(e){return`Showing the last sample; refresh failed: ${z(e)}.`}async function we(){try{const e=await y.supervisorStatus();return e.available?{status:"available",data:e.status,staleReason:null}:e.status!==null?{status:"available",data:e.status,staleReason:e.reason}:{status:"unavailable",error:z(e.reason)}}catch(e){return{status:"unavailable",error:E(e,"supervisor status unavailable")}}}async function Ne(){try{return{status:"available",data:await y.localToolVersions()}}catch{return{status:"unavailable",error:"local tool versions unavailable"}}}async function Se(){try{return await y.doltTrend()}catch{return{available:!1,reason:"sample_failed",samples:[]}}}async function ke(){try{return await y.rigStoreHealth()}catch{return{available:!1,reason:"fetch_failed",rigs:[]}}}function $e(e,a){const s=[];if(a===null)s.push("Supervisor state still loading.");else if(a.status==="available"){const r=a.data,c=r.status==="ok"?"healthy":r.status;r.city!==void 0?s.push(`Supervisor ${c} on ${r.city}, uptime ${j(r.uptime_sec)}.`):s.push(`Supervisor ${c}, uptime ${j(r.uptime_sec)}.`)}else s.push("Supervisor unreachable.");if(e===null)return s.push("Host health unavailable."),s.join(" ");const o=Math.round(100*(1-e.host.free_mem_bytes/e.host.total_mem_bytes));return s.push(`Memory at ${o}%; ${e.host.cpu_count} CPUs averaging ${e.host.load_avg_1.toFixed(2)} load.`),s.join(" ")}function Re(e){return e.status==="unavailable"?{tone:"stuck",label:"offline"}:e.data.status==="ok"?{tone:"ok",label:"healthy"}:{tone:"warn",label:e.data.status}}function He(e){const a=e.host.free_mem_bytes/e.host.total_mem_bytes;if(a<.05)return{tone:"stuck",label:"memory critical"};if(a<.1)return{tone:"warn",label:"memory low"};if(e.host.load_avg_1>e.host.cpu_count*1.5)return{tone:"warn",label:"load high"}}function K(e){if(e===null)return{status:"unavailable",reason:"supervisor status still loading"};if(e.status==="unavailable")return{status:"unavailable",reason:e.error};const a=e.data.store_health;return a===void 0?{status:"unavailable",reason:"supervisor did not report store_health"}:{status:"available",value:a,source:"supervisor status.store_health",...e.staleReason!==null?{stale:I(e.staleReason)}:{}}}function Ce(e){return e===null?{status:"unavailable",reason:"supervisor status still loading"}:e.status==="unavailable"?{status:"unavailable",reason:e.error}:{status:"available",value:e.data.work,source:"supervisor status.work",...e.staleReason!==null?{stale:I(e.staleReason)}:{}}}function Le(e){const a=K(e);if(a.status==="unavailable")return{status:"unavailable",reason:a.reason};const s=a.value;return{status:"available",source:"supervisor status.store_health (threshold vs actual)",...a.stale!==void 0?{stale:a.stale}:{},value:[{label:"Dolt MB-per-row ratio",recommended:`<= ${s.threshold_mb_per_row}`,loaded:String(s.ratio_mb_per_row),withinRecommendation:!s.warning}]}}function Te(e){return typeof e=="bigint"?Number(e):e}function j(e){if(e<60)return`${e}s`;if(e<3600)return`${Math.round(e/60)}m`;if(e<86400)return`${Math.round(e/3600)}h`;const a=Math.floor(e/86400),s=Math.round(e%86400/3600);return s>0?`${a}d ${s}h`:`${a}d`}export{Ee as HealthPage}; diff --git a/internal/api/dashboardspa/dist/assets/LiveSessionPeek-Cjv3DcC3.js b/internal/api/dashboardspa/dist/assets/LiveSessionPeek-jm19JJ4Z.js similarity index 99% rename from internal/api/dashboardspa/dist/assets/LiveSessionPeek-Cjv3DcC3.js rename to internal/api/dashboardspa/dist/assets/LiveSessionPeek-jm19JJ4Z.js index dc5a351bd6..baa8c7aedd 100644 --- a/internal/api/dashboardspa/dist/assets/LiveSessionPeek-Cjv3DcC3.js +++ b/internal/api/dashboardspa/dist/assets/LiveSessionPeek-jm19JJ4Z.js @@ -1,4 +1,4 @@ -import{r as d,a4 as O,I,p as C,x as L,a5 as A,H as $,j as l,S as B}from"./index-BFDP6Xwd.js";import{a as M,b as U,f as v}from"./time-D9v0saHV.js";import{a as D}from"./format-fte2CeYD.js";import{P as F}from"./constants-DOaI3lZl.js";function q(t,e){const[s,r]=d.useState({status:"idle",stream:{status:"idle"}}),n=d.useRef(!1);return d.useEffect(()=>{if(n.current=!1,!t){r({status:"idle",stream:{status:"idle"}});return}let i=!1,a=null;const u=e&&typeof EventSource<"u";return r({status:"loading",stream:{status:u?"connecting":"idle"}}),O(t).then(c=>{if(!i&&(r({status:"ready",result:c,stream:{status:u?"connecting":"idle"}}),u)){a=new EventSource(I().sessionStreamUrl(G("open supervisor session stream"),t),{withCredentials:!0}),a.onopen=()=>{i||r(p=>p.status==="ready"?{...p,stream:{status:"open"}}:p)};const f=p=>{if(i)return;const h=z(p.data);h.kind==="invalid"&&H(t,n),r(_=>{const m=_.status==="ready"?_.result:c;return h.kind==="invalid"?{status:"ready",result:m,stream:{status:"degraded",error:h.error}}:h.kind==="snapshot"?{status:"ready",result:h.result,stream:{status:"open"}}:{status:"ready",result:{...m,turns:[...m.turns,h.turn],total_chars:m.total_chars+h.turn.text.length,captured_at:new Date().toISOString()},stream:{status:"open"}}})};a.onmessage=f,a.addEventListener("turn",f),a.onerror=()=>{if(i)return;const p=a?.readyState===EventSource.CLOSED?"closed":"connecting";r(h=>h.status==="ready"?{...h,stream:{status:p}}:h)}}},c=>{i||(N("load transcript",t,c),r({status:"failed",error:C(c)||"Failed to load session.",stream:{status:"idle"}}))}),()=>{i=!0,a?.close()}},[t,e]),s}function H(t,e){e.current||(e.current=!0,N("parse stream event",t,b))}function N(t,e,s){L({component:"session-stream",operation:t,message:`${e}: ${C(s)}`})}function G(t){const e=$();if(e===null)throw new Error(`${t} called before an active city was resolved`);return e}const b="Malformed session stream event.";function z(t){let e;try{e=JSON.parse(t)}catch{return{kind:"invalid",error:b}}if(!T(e))return{kind:"invalid",error:b};const s=V(e);return s?{kind:"snapshot",result:s}:typeof e.text!="string"?{kind:"invalid",error:b}:{kind:"turn",turn:{role:typeof e.role=="string"?e.role:"assistant",text:e.text}}}function V(t){if(!Array.isArray(t.turns))return null;const e=t.turns.flatMap(i=>!T(i)||typeof i.text!="string"?[]:[{role:typeof i.role=="string"?i.role:"assistant",text:i.text}]);if(e.length!==t.turns.length)return null;const s=typeof t.session_id=="string"?t.session_id:typeof t.id=="string"?t.id:"";if(!s)return null;const r=typeof t.total_chars=="number"?t.total_chars:e.reduce((i,a)=>i+a.text.length,0);return{...A({id:s,template:typeof t.template=="string"?t.template:"",provider:typeof t.provider=="string"?t.provider:"",format:typeof t.format=="string"?t.format:"conversation",turns:e},typeof t.captured_at=="string"?t.captured_at:new Date().toISOString()),total_chars:r,truncated:t.truncated===!0}}function T(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)}var S=function(t,e){return Object.defineProperty?Object.defineProperty(t,"raw",{value:e}):t.raw=e,t},o;(function(t){t[t.EOS=0]="EOS",t[t.Text=1]="Text",t[t.Incomplete=2]="Incomplete",t[t.ESC=3]="ESC",t[t.Unknown=4]="Unknown",t[t.SGR=5]="SGR",t[t.OSCURL=6]="OSCURL"})(o||(o={}));class P{constructor(){this.VERSION="6.0.6",this.setup_palettes(),this._use_classes=!1,this.bold=!1,this.faint=!1,this.italic=!1,this.underline=!1,this.fg=this.bg=null,this._buffer="",this._url_allowlist={http:1,https:1},this._escape_html=!0,this.boldStyle="font-weight:bold",this.faintStyle="opacity:0.7",this.italicStyle="font-style:italic",this.underlineStyle="text-decoration:underline"}set use_classes(e){this._use_classes=e}get use_classes(){return this._use_classes}set url_allowlist(e){this._url_allowlist=e}get url_allowlist(){return this._url_allowlist}set escape_html(e){this._escape_html=e}get escape_html(){return this._escape_html}set boldStyle(e){this._boldStyle=e}get boldStyle(){return this._boldStyle}set faintStyle(e){this._faintStyle=e}get faintStyle(){return this._faintStyle}set italicStyle(e){this._italicStyle=e}get italicStyle(){return this._italicStyle}set underlineStyle(e){this._underlineStyle=e}get underlineStyle(){return this._underlineStyle}setup_palettes(){this.ansi_colors=[[{rgb:[0,0,0],class_name:"ansi-black"},{rgb:[187,0,0],class_name:"ansi-red"},{rgb:[0,187,0],class_name:"ansi-green"},{rgb:[187,187,0],class_name:"ansi-yellow"},{rgb:[0,0,187],class_name:"ansi-blue"},{rgb:[187,0,187],class_name:"ansi-magenta"},{rgb:[0,187,187],class_name:"ansi-cyan"},{rgb:[255,255,255],class_name:"ansi-white"}],[{rgb:[85,85,85],class_name:"ansi-bright-black"},{rgb:[255,85,85],class_name:"ansi-bright-red"},{rgb:[0,255,0],class_name:"ansi-bright-green"},{rgb:[255,255,85],class_name:"ansi-bright-yellow"},{rgb:[85,85,255],class_name:"ansi-bright-blue"},{rgb:[255,85,255],class_name:"ansi-bright-magenta"},{rgb:[85,255,255],class_name:"ansi-bright-cyan"},{rgb:[255,255,255],class_name:"ansi-bright-white"}]],this.palette_256=[],this.ansi_colors.forEach(r=>{r.forEach(n=>{this.palette_256.push(n)})});let e=[0,95,135,175,215,255];for(let r=0;r<6;++r)for(let n=0;n<6;++n)for(let i=0;i<6;++i){let a={rgb:[e[r],e[n],e[i]],class_name:"truecolor"};this.palette_256.push(a)}let s=8;for(let r=0;r<24;++r,s+=10){let n={rgb:[s,s,s],class_name:"truecolor"};this.palette_256.push(n)}}escape_txt_for_html(e){return this._escape_html?e.replace(/[&<>"']/gm,s=>{if(s==="&")return"&";if(s==="<")return"<";if(s===">")return">";if(s==='"')return""";if(s==="'")return"'"}):e}append_buffer(e){var s=this._buffer+e;this._buffer=s}get_next_packet(){var e={kind:o.EOS,text:"",url:""},s=this._buffer.length;if(s==0)return e;var r=this._buffer.indexOf("\x1B");if(r==-1)return e.kind=o.Text,e.text=this._buffer,this._buffer="",e;if(r>0)return e.kind=o.Text,e.text=this._buffer.slice(0,r),this._buffer=this._buffer.slice(r),e;if(r==0){if(s<3)return e.kind=o.Incomplete,e;var n=this._buffer.charAt(1);if(n!="["&&n!="]"&&n!="(")return e.kind=o.ESC,e.text=this._buffer.slice(0,1),this._buffer=this._buffer.slice(1),e;if(n=="["){this._csi_regex||(this._csi_regex=y(w||(w=S([` +import{r as d,a4 as O,I,p as C,x as L,a5 as A,H as $,j as l,S as B}from"./index-C20tCZFz.js";import{a as M,b as U,f as v}from"./time-D9v0saHV.js";import{a as D}from"./format-fte2CeYD.js";import{P as F}from"./constants-DBKWGg29.js";function q(t,e){const[s,r]=d.useState({status:"idle",stream:{status:"idle"}}),n=d.useRef(!1);return d.useEffect(()=>{if(n.current=!1,!t){r({status:"idle",stream:{status:"idle"}});return}let i=!1,a=null;const u=e&&typeof EventSource<"u";return r({status:"loading",stream:{status:u?"connecting":"idle"}}),O(t).then(c=>{if(!i&&(r({status:"ready",result:c,stream:{status:u?"connecting":"idle"}}),u)){a=new EventSource(I().sessionStreamUrl(G("open supervisor session stream"),t),{withCredentials:!0}),a.onopen=()=>{i||r(p=>p.status==="ready"?{...p,stream:{status:"open"}}:p)};const f=p=>{if(i)return;const h=z(p.data);h.kind==="invalid"&&H(t,n),r(_=>{const m=_.status==="ready"?_.result:c;return h.kind==="invalid"?{status:"ready",result:m,stream:{status:"degraded",error:h.error}}:h.kind==="snapshot"?{status:"ready",result:h.result,stream:{status:"open"}}:{status:"ready",result:{...m,turns:[...m.turns,h.turn],total_chars:m.total_chars+h.turn.text.length,captured_at:new Date().toISOString()},stream:{status:"open"}}})};a.onmessage=f,a.addEventListener("turn",f),a.onerror=()=>{if(i)return;const p=a?.readyState===EventSource.CLOSED?"closed":"connecting";r(h=>h.status==="ready"?{...h,stream:{status:p}}:h)}}},c=>{i||(N("load transcript",t,c),r({status:"failed",error:C(c)||"Failed to load session.",stream:{status:"idle"}}))}),()=>{i=!0,a?.close()}},[t,e]),s}function H(t,e){e.current||(e.current=!0,N("parse stream event",t,b))}function N(t,e,s){L({component:"session-stream",operation:t,message:`${e}: ${C(s)}`})}function G(t){const e=$();if(e===null)throw new Error(`${t} called before an active city was resolved`);return e}const b="Malformed session stream event.";function z(t){let e;try{e=JSON.parse(t)}catch{return{kind:"invalid",error:b}}if(!T(e))return{kind:"invalid",error:b};const s=V(e);return s?{kind:"snapshot",result:s}:typeof e.text!="string"?{kind:"invalid",error:b}:{kind:"turn",turn:{role:typeof e.role=="string"?e.role:"assistant",text:e.text}}}function V(t){if(!Array.isArray(t.turns))return null;const e=t.turns.flatMap(i=>!T(i)||typeof i.text!="string"?[]:[{role:typeof i.role=="string"?i.role:"assistant",text:i.text}]);if(e.length!==t.turns.length)return null;const s=typeof t.session_id=="string"?t.session_id:typeof t.id=="string"?t.id:"";if(!s)return null;const r=typeof t.total_chars=="number"?t.total_chars:e.reduce((i,a)=>i+a.text.length,0);return{...A({id:s,template:typeof t.template=="string"?t.template:"",provider:typeof t.provider=="string"?t.provider:"",format:typeof t.format=="string"?t.format:"conversation",turns:e},typeof t.captured_at=="string"?t.captured_at:new Date().toISOString()),total_chars:r,truncated:t.truncated===!0}}function T(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)}var S=function(t,e){return Object.defineProperty?Object.defineProperty(t,"raw",{value:e}):t.raw=e,t},o;(function(t){t[t.EOS=0]="EOS",t[t.Text=1]="Text",t[t.Incomplete=2]="Incomplete",t[t.ESC=3]="ESC",t[t.Unknown=4]="Unknown",t[t.SGR=5]="SGR",t[t.OSCURL=6]="OSCURL"})(o||(o={}));class P{constructor(){this.VERSION="6.0.6",this.setup_palettes(),this._use_classes=!1,this.bold=!1,this.faint=!1,this.italic=!1,this.underline=!1,this.fg=this.bg=null,this._buffer="",this._url_allowlist={http:1,https:1},this._escape_html=!0,this.boldStyle="font-weight:bold",this.faintStyle="opacity:0.7",this.italicStyle="font-style:italic",this.underlineStyle="text-decoration:underline"}set use_classes(e){this._use_classes=e}get use_classes(){return this._use_classes}set url_allowlist(e){this._url_allowlist=e}get url_allowlist(){return this._url_allowlist}set escape_html(e){this._escape_html=e}get escape_html(){return this._escape_html}set boldStyle(e){this._boldStyle=e}get boldStyle(){return this._boldStyle}set faintStyle(e){this._faintStyle=e}get faintStyle(){return this._faintStyle}set italicStyle(e){this._italicStyle=e}get italicStyle(){return this._italicStyle}set underlineStyle(e){this._underlineStyle=e}get underlineStyle(){return this._underlineStyle}setup_palettes(){this.ansi_colors=[[{rgb:[0,0,0],class_name:"ansi-black"},{rgb:[187,0,0],class_name:"ansi-red"},{rgb:[0,187,0],class_name:"ansi-green"},{rgb:[187,187,0],class_name:"ansi-yellow"},{rgb:[0,0,187],class_name:"ansi-blue"},{rgb:[187,0,187],class_name:"ansi-magenta"},{rgb:[0,187,187],class_name:"ansi-cyan"},{rgb:[255,255,255],class_name:"ansi-white"}],[{rgb:[85,85,85],class_name:"ansi-bright-black"},{rgb:[255,85,85],class_name:"ansi-bright-red"},{rgb:[0,255,0],class_name:"ansi-bright-green"},{rgb:[255,255,85],class_name:"ansi-bright-yellow"},{rgb:[85,85,255],class_name:"ansi-bright-blue"},{rgb:[255,85,255],class_name:"ansi-bright-magenta"},{rgb:[85,255,255],class_name:"ansi-bright-cyan"},{rgb:[255,255,255],class_name:"ansi-bright-white"}]],this.palette_256=[],this.ansi_colors.forEach(r=>{r.forEach(n=>{this.palette_256.push(n)})});let e=[0,95,135,175,215,255];for(let r=0;r<6;++r)for(let n=0;n<6;++n)for(let i=0;i<6;++i){let a={rgb:[e[r],e[n],e[i]],class_name:"truecolor"};this.palette_256.push(a)}let s=8;for(let r=0;r<24;++r,s+=10){let n={rgb:[s,s,s],class_name:"truecolor"};this.palette_256.push(n)}}escape_txt_for_html(e){return this._escape_html?e.replace(/[&<>"']/gm,s=>{if(s==="&")return"&";if(s==="<")return"<";if(s===">")return">";if(s==='"')return""";if(s==="'")return"'"}):e}append_buffer(e){var s=this._buffer+e;this._buffer=s}get_next_packet(){var e={kind:o.EOS,text:"",url:""},s=this._buffer.length;if(s==0)return e;var r=this._buffer.indexOf("\x1B");if(r==-1)return e.kind=o.Text,e.text=this._buffer,this._buffer="",e;if(r>0)return e.kind=o.Text,e.text=this._buffer.slice(0,r),this._buffer=this._buffer.slice(r),e;if(r==0){if(s<3)return e.kind=o.Incomplete,e;var n=this._buffer.charAt(1);if(n!="["&&n!="]"&&n!="(")return e.kind=o.ESC,e.text=this._buffer.slice(0,1),this._buffer=this._buffer.slice(1),e;if(n=="["){this._csi_regex||(this._csi_regex=y(w||(w=S([` ^ # beginning of line # # First attempt diff --git a/internal/api/dashboardspa/dist/assets/Mail-CfeMOQZF.js b/internal/api/dashboardspa/dist/assets/Mail-767k9Nkh.js similarity index 98% rename from internal/api/dashboardspa/dist/assets/Mail-CfeMOQZF.js rename to internal/api/dashboardspa/dist/assets/Mail-767k9Nkh.js index 500f3dff5f..9b9ba781eb 100644 --- a/internal/api/dashboardspa/dist/assets/Mail-CfeMOQZF.js +++ b/internal/api/dashboardspa/dist/assets/Mail-767k9Nkh.js @@ -1,3 +1,3 @@ -import{j as e,r,w as re,M as L,N as qe,I as F,J as B,v as Ce,g as Me,z as ae,R as ne,S as se,B as M,i as _,a as Ue,K as Ye,O as Ae,P as Le,u as Ke,b as Ve,A as Ge,Q as be,T as Qe,U as Je,V as Re,W as Ie}from"./index-BFDP6Xwd.js";import{a as Xe,L as Ze,m as et}from"./projectOf-CJPpTC86.js";import{a as tt,r as je}from"./routeHighlight-B30gQO2o.js";import{u as at,F as st}from"./useListFilters-CE9qAvrH.js";import{T as rt}from"./Table-3q0HSJQI.js";import{M as _e,P as nt}from"./constants-DOaI3lZl.js";import{P as lt}from"./PageHeader-5RHLpIfH.js";import{F as P}from"./Field-BpdGqWpv.js";import{f as it}from"./time-D9v0saHV.js";function q(t){const a=t.trim();if(a.length===0||!a.includes("/")&&!a.includes("\\"))return a;const i=a.split(/[\\/]/).filter(m=>m.length>0),c=i[i.length-1];if(c===void 0)return a;const n=i[i.length-2];if(n===void 0)return c;const l=c.startsWith(`${n}-`)?c.slice(n.length+1):c;return`${Xe(n)} · ${l}`}function ot({collapsed:t,onToggle:a,children:i,className:c="w-full flex items-baseline justify-between gap-4 focus-mark",glyphClassName:n}){return e.jsx("button",{type:"button",onClick:a,className:c,"aria-expanded":!t,children:i({glyph:e.jsx(ct,{collapsed:t,className:n??""})})})}function ct({collapsed:t,className:a=""}){return e.jsx("span",{"aria-hidden":!0,className:`inline-block text-fg-faint transition-transform duration-150 ease-out-quart ${a}`,style:{transform:t?"rotate(-90deg)":"rotate(0deg)"},children:"▾"})}function dt({project:t,count:a,collapsed:i,onToggle:c,collapsible:n=!0}){return n?e.jsx(ot,{collapsed:i,onToggle:c,className:"group flex items-baseline gap-2 w-full text-left focus-mark rounded-sm py-1",glyphClassName:"group-hover:text-fg-muted tnum w-3",children:({glyph:l})=>e.jsxs(e.Fragment,{children:[l,e.jsx("span",{className:"text-title font-medium text-fg group-hover:text-fg",children:t}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:a})]})}):e.jsxs("div",{role:"heading","aria-level":2,className:"flex items-baseline gap-2 py-1 text-label uppercase tracking-wider text-fg-faint",children:[e.jsx("span",{"aria-hidden":!0,children:"·"}),e.jsx("span",{children:t}),e.jsx("span",{"aria-hidden":!0,children:"·"})]})}function ut({groups:t,columns:a,rowKey:i,onToggleProject:c,onRowClick:n,rowProps:l,emptyMessage:m,perProjectEmpty:b,initialSort:h}){return t.length===0?e.jsx("p",{className:"py-10 text-center text-fg-muted italic",children:m}):e.jsx("div",{className:"space-y-8",children:t.map(d=>e.jsxs("section",{children:[e.jsx(dt,{project:d.project,count:d.totalInProject,collapsed:d.collapsed,onToggle:()=>c(d.projectKey),collapsible:d.collapsible}),!d.collapsed&&e.jsx(rt,{columns:a,rows:d.rows,rowKey:i,empty:b??"No items.",...n!==void 0?{onRowClick:n}:{},...l!==void 0?{rowProps:l}:{},...h!==void 0?{initialSort:h}:{}})]},d.projectKey))})}const ye="border-rule pb-6 border-b sm:shrink-0 sm:pr-6 sm:pb-0 sm:border-b-0 sm:border-r";function mt({buckets:t,loading:a,sessionsUnavailable:i,value:c,onChange:n,onReset:l,isOperator:m}){const[b,h]=r.useState(!1),[d,S]=r.useState(""),{operatorAlias:y,operatorWireAlias:u}=re(),A=L(c,y),x=r.useMemo(()=>{const f=d.trim().toLowerCase();return t.map(g=>({tier:g.tier,aliases:g.aliases.filter(v=>v.toLowerCase()===u?!1:f.length===0?!0:L(v,y).toLowerCase().includes(f)||v.toLowerCase().includes(f))})).filter(g=>g.aliases.length>0)},[t,d,y,u]),R=f=>{n(f),h(!1),S("")};return b?e.jsxs("aside",{className:`${ye} sm:w-64`,children:[e.jsx("button",{type:"button",onClick:()=>h(!1),"aria-expanded":!0,className:"text-label uppercase tracking-wider text-fg-muted hover:text-fg focus-mark rounded-sm",children:"▾ Agents"}),e.jsxs("div",{className:"mt-2 text-label uppercase tracking-wider text-fg-faint",children:[m?"Reading as":e.jsx("span",{className:"text-accent",children:"▲ Reading as"})," ",e.jsx("span",{className:`not-italic ${m?"text-fg-muted":"text-accent"}`,children:A})]}),e.jsx("div",{className:"mt-3 border-b border-rule pb-1",children:e.jsx("input",{type:"search",value:d,onChange:f=>S(f.target.value),placeholder:"Find an agent","aria-label":"Find an agent",autoFocus:!0,className:"w-full bg-transparent border-0 text-body text-fg placeholder:text-fg-faint focus:outline-none focus:ring-0 px-0 py-0.5"})}),e.jsxs("div",{className:"mt-3 max-h-[28rem] overflow-y-auto -mr-2 pr-2 space-y-4",children:[x.length===0?e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:a?"Loading aliases":"No agents match."}):x.map(f=>e.jsxs("div",{children:[e.jsx("div",{className:"text-label uppercase tracking-wider text-fg-faint mb-1",children:qe(f.tier)}),e.jsx("ul",{className:"space-y-0.5",children:f.aliases.map(g=>{const v=g.toLowerCase()===c.toLowerCase();return e.jsx("li",{children:e.jsx("button",{type:"button",onClick:()=>R(g),"aria-current":v,className:`block w-full text-left truncate text-body transition-colors duration-150 ease-out-quart focus-mark rounded-sm py-0.5 ${v?"text-fg font-semibold":"text-fg-muted hover:text-fg"}`,title:L(g,y),children:L(g,y)})},g)})})]},f.tier)),a&&x.length>0&&e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:"Loading more agents"}),!a&&i&&x.length>0&&(ft(x)?e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:"Agent list and mail history both unavailable."}):e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:"Agent list unavailable; showing mail-derived aliases only."}))]}),!m&&e.jsxs("div",{className:"mt-4 pt-3 border-t border-rule space-y-2",children:[e.jsx("button",{type:"button",onClick:l,className:"block text-label uppercase tracking-wider text-fg-muted hover:text-fg focus-mark underline decoration-dotted underline-offset-2 rounded-sm",children:"Back to operator"}),e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:"Read-only. Sends always go from the operator."})]})]}):e.jsxs("aside",{className:`${ye} sm:w-44`,children:[e.jsx("button",{type:"button",onClick:()=>h(!0),"aria-expanded":!1,className:"text-label uppercase tracking-wider text-fg-muted hover:text-fg focus-mark rounded-sm",children:"▸ Agents"}),e.jsx("div",{className:"mt-4 text-label uppercase tracking-wider text-fg-faint",children:m?"Reading as":e.jsx("span",{className:"text-accent",children:"▲ Reading as"})}),e.jsx("div",{className:`mt-1 text-body truncate ${m?"text-fg":"text-accent font-medium"}`,title:A,children:A}),!m&&e.jsxs(e.Fragment,{children:[e.jsx("button",{type:"button",onClick:l,className:"mt-3 block text-label uppercase tracking-wider text-fg-muted hover:text-fg focus-mark underline decoration-dotted underline-offset-2 rounded-sm",children:"Back to operator"}),e.jsx("p",{className:"mt-2 text-label uppercase tracking-wider text-fg-faint italic",children:"Read-only. Sends go from the operator."})]})]})}function ft(t){let a=0;for(const i of t)if(a+=i.aliases.length,a>1)return!1;return a<=1}async function pt(t,a){await F().sendMail(B("send supervisor mail"),{...t,from:a})}async function ve(t){await F().markMailRead(B("mark supervisor mail read"),t.id,U(t))}async function we(t){await F().markMailUnread(B("mark supervisor mail unread"),t.id,U(t))}async function xt(t){await F().archiveMail(B("archive supervisor mail"),t.id,U(t))}async function ht(t,a,i){await F().replyMail(B("reply supervisor mail"),t.id,{...a,from:i},U(t))}function U(t){return t.rig===void 0||t.rig.length===0?void 0:{rig:t.rig}}function gt({open:t,onClose:a,onSent:i}){const{viewingAs:c}=Ce(),n=Me(),{operatorAlias:l,operatorWireAlias:m}=re(),[b,h]=r.useState(""),[d,S]=r.useState(""),[y,u]=r.useState(""),[A,x]=r.useState(!1),[R,f]=r.useState(null);r.useEffect(()=>{t||(h(""),S(""),u(""),f(null))},[t]);const g=r.useCallback(async()=>{if(!n){x(!0),f(null);try{await pt({to:b,subject:d,body:y},m),i()}catch(N){f(ae(N,"send failed"))}finally{x(!1)}}},[y,i,n,d,b,m]),v=!n&&c.isOperator&&b.length>0&&d.length>0&&y.length>0&&!A;return e.jsx(_e,{open:t,onClose:a,title:"New message",caption:"Sends from the operator. Reading-as has no effect on the sender.",widthClass:"max-w-2xl",footer:e.jsxs(e.Fragment,{children:[e.jsx(M,{tone:"quiet",size:"sm",onClick:a,children:"Cancel"}),e.jsx(M,{tone:"accent",size:"sm",disabled:!v,title:n?_:void 0,onClick:()=>{g()},children:A?"Sending":"Send"})]}),children:e.jsxs("div",{className:"space-y-4",children:[e.jsx(P,{label:"From",variant:"form",children:e.jsx("input",{type:"text",value:c.isOperator?L(l,l):`${L(l,l)} (reading-as does not change sender)`,disabled:!0,className:"w-full bg-transparent border-0 border-b border-rule pb-1 text-body text-fg-muted italic"})}),e.jsx(P,{label:"To (alias)",variant:"form",children:e.jsx("input",{type:"text",autoFocus:!0,value:b,onChange:N=>h(N.target.value),placeholder:"mayor, mechanic, scix-worker, …",className:"w-full bg-transparent border-0 border-b border-rule pb-1 text-body text-fg placeholder:text-fg-faint focus:border-accent focus:outline-none transition-colors"})}),e.jsx(P,{label:"Subject",variant:"form",children:e.jsx("input",{type:"text",value:d,onChange:N=>S(N.target.value),maxLength:200,className:"w-full bg-transparent border-0 border-b border-rule pb-1 text-body text-fg focus:border-accent focus:outline-none transition-colors"})}),e.jsx(P,{label:"Body",variant:"form",children:e.jsx("textarea",{value:y,onChange:N=>u(N.target.value),rows:10,maxLength:16*1024,className:"w-full bg-surface-tint border border-rule rounded-sm px-3 py-2 text-body text-fg focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40 resize-y"})}),n&&e.jsx(ne,{}),!c.isOperator&&e.jsx(se,{tone:"warn",label:`Reading as ${L(c.alias,l)}. Sends from this modal are structurally locked to the operator regardless.`}),R&&e.jsx(se,{tone:"stuck",label:R})]})})}function Ne({message:t,attentionSeverity:a=null}){return e.jsxs("article",{...bt(a),className:"space-y-3 pb-4 border-b border-rule last:border-0",children:[e.jsxs("header",{className:"flex items-baseline justify-between gap-3",children:[e.jsxs("div",{className:"text-label uppercase tracking-wider text-fg-muted truncate",children:[e.jsx("span",{className:"text-fg font-medium",children:q(t.from)}),e.jsx("span",{className:"mx-1.5 text-fg-faint",children:"→"}),e.jsx("span",{children:q(t.to)})]}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:jt(t.created_at)})]}),e.jsx("p",{className:"text-title font-semibold text-fg",children:t.subject}),e.jsx(se,{tone:"warn",label:nt}),e.jsx("pre",{className:"text-body whitespace-pre-wrap leading-relaxed text-fg overflow-x-auto",children:t.body})]})}function bt(t){return t===null?{}:{"data-attention-severity":t}}function jt(t){const a=Date.parse(t);return Number.isFinite(a)?new Date(a).toLocaleString(void 0,{month:"short",day:"numeric",hour:"numeric",minute:"2-digit"}):"·"}const ke=[{id:"unread",label:"unread",match:t=>!t.read},{id:"read",label:"read",match:t=>t.read}],yt={id:"needs-you",label:"needs you",match:t=>!t.read&&!Je(t.from)},vt=t=>[t.from,t.to,t.subject,t.rig,t.body.split(` +import{j as e,r,w as re,M as L,N as qe,I as F,J as B,v as Ce,g as Me,z as ae,R as ne,S as se,B as M,i as _,a as Ue,K as Ye,O as Ae,P as Le,u as Ke,b as Ve,A as Ge,Q as be,T as Qe,U as Je,V as Re,W as Ie}from"./index-C20tCZFz.js";import{a as Xe,L as Ze,m as et}from"./projectOf-CwPPScnJ.js";import{a as tt,r as je}from"./routeHighlight-B30gQO2o.js";import{u as at,F as st}from"./useListFilters-C0Eq1DLc.js";import{T as rt}from"./Table-DojZJIvD.js";import{M as _e,P as nt}from"./constants-DBKWGg29.js";import{P as lt}from"./PageHeader-D_D-jYn1.js";import{F as P}from"./Field-Dsl4x4KL.js";import{f as it}from"./time-D9v0saHV.js";function q(t){const a=t.trim();if(a.length===0||!a.includes("/")&&!a.includes("\\"))return a;const i=a.split(/[\\/]/).filter(m=>m.length>0),c=i[i.length-1];if(c===void 0)return a;const n=i[i.length-2];if(n===void 0)return c;const l=c.startsWith(`${n}-`)?c.slice(n.length+1):c;return`${Xe(n)} · ${l}`}function ot({collapsed:t,onToggle:a,children:i,className:c="w-full flex items-baseline justify-between gap-4 focus-mark",glyphClassName:n}){return e.jsx("button",{type:"button",onClick:a,className:c,"aria-expanded":!t,children:i({glyph:e.jsx(ct,{collapsed:t,className:n??""})})})}function ct({collapsed:t,className:a=""}){return e.jsx("span",{"aria-hidden":!0,className:`inline-block text-fg-faint transition-transform duration-150 ease-out-quart ${a}`,style:{transform:t?"rotate(-90deg)":"rotate(0deg)"},children:"▾"})}function dt({project:t,count:a,collapsed:i,onToggle:c,collapsible:n=!0}){return n?e.jsx(ot,{collapsed:i,onToggle:c,className:"group flex items-baseline gap-2 w-full text-left focus-mark rounded-sm py-1",glyphClassName:"group-hover:text-fg-muted tnum w-3",children:({glyph:l})=>e.jsxs(e.Fragment,{children:[l,e.jsx("span",{className:"text-title font-medium text-fg group-hover:text-fg",children:t}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:a})]})}):e.jsxs("div",{role:"heading","aria-level":2,className:"flex items-baseline gap-2 py-1 text-label uppercase tracking-wider text-fg-faint",children:[e.jsx("span",{"aria-hidden":!0,children:"·"}),e.jsx("span",{children:t}),e.jsx("span",{"aria-hidden":!0,children:"·"})]})}function ut({groups:t,columns:a,rowKey:i,onToggleProject:c,onRowClick:n,rowProps:l,emptyMessage:m,perProjectEmpty:b,initialSort:h}){return t.length===0?e.jsx("p",{className:"py-10 text-center text-fg-muted italic",children:m}):e.jsx("div",{className:"space-y-8",children:t.map(d=>e.jsxs("section",{children:[e.jsx(dt,{project:d.project,count:d.totalInProject,collapsed:d.collapsed,onToggle:()=>c(d.projectKey),collapsible:d.collapsible}),!d.collapsed&&e.jsx(rt,{columns:a,rows:d.rows,rowKey:i,empty:b??"No items.",...n!==void 0?{onRowClick:n}:{},...l!==void 0?{rowProps:l}:{},...h!==void 0?{initialSort:h}:{}})]},d.projectKey))})}const ye="border-rule pb-6 border-b sm:shrink-0 sm:pr-6 sm:pb-0 sm:border-b-0 sm:border-r";function mt({buckets:t,loading:a,sessionsUnavailable:i,value:c,onChange:n,onReset:l,isOperator:m}){const[b,h]=r.useState(!1),[d,S]=r.useState(""),{operatorAlias:y,operatorWireAlias:u}=re(),A=L(c,y),x=r.useMemo(()=>{const f=d.trim().toLowerCase();return t.map(g=>({tier:g.tier,aliases:g.aliases.filter(v=>v.toLowerCase()===u?!1:f.length===0?!0:L(v,y).toLowerCase().includes(f)||v.toLowerCase().includes(f))})).filter(g=>g.aliases.length>0)},[t,d,y,u]),R=f=>{n(f),h(!1),S("")};return b?e.jsxs("aside",{className:`${ye} sm:w-64`,children:[e.jsx("button",{type:"button",onClick:()=>h(!1),"aria-expanded":!0,className:"text-label uppercase tracking-wider text-fg-muted hover:text-fg focus-mark rounded-sm",children:"▾ Agents"}),e.jsxs("div",{className:"mt-2 text-label uppercase tracking-wider text-fg-faint",children:[m?"Reading as":e.jsx("span",{className:"text-accent",children:"▲ Reading as"})," ",e.jsx("span",{className:`not-italic ${m?"text-fg-muted":"text-accent"}`,children:A})]}),e.jsx("div",{className:"mt-3 border-b border-rule pb-1",children:e.jsx("input",{type:"search",value:d,onChange:f=>S(f.target.value),placeholder:"Find an agent","aria-label":"Find an agent",autoFocus:!0,className:"w-full bg-transparent border-0 text-body text-fg placeholder:text-fg-faint focus:outline-none focus:ring-0 px-0 py-0.5"})}),e.jsxs("div",{className:"mt-3 max-h-[28rem] overflow-y-auto -mr-2 pr-2 space-y-4",children:[x.length===0?e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:a?"Loading aliases":"No agents match."}):x.map(f=>e.jsxs("div",{children:[e.jsx("div",{className:"text-label uppercase tracking-wider text-fg-faint mb-1",children:qe(f.tier)}),e.jsx("ul",{className:"space-y-0.5",children:f.aliases.map(g=>{const v=g.toLowerCase()===c.toLowerCase();return e.jsx("li",{children:e.jsx("button",{type:"button",onClick:()=>R(g),"aria-current":v,className:`block w-full text-left truncate text-body transition-colors duration-150 ease-out-quart focus-mark rounded-sm py-0.5 ${v?"text-fg font-semibold":"text-fg-muted hover:text-fg"}`,title:L(g,y),children:L(g,y)})},g)})})]},f.tier)),a&&x.length>0&&e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:"Loading more agents"}),!a&&i&&x.length>0&&(ft(x)?e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:"Agent list and mail history both unavailable."}):e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:"Agent list unavailable; showing mail-derived aliases only."}))]}),!m&&e.jsxs("div",{className:"mt-4 pt-3 border-t border-rule space-y-2",children:[e.jsx("button",{type:"button",onClick:l,className:"block text-label uppercase tracking-wider text-fg-muted hover:text-fg focus-mark underline decoration-dotted underline-offset-2 rounded-sm",children:"Back to operator"}),e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:"Read-only. Sends always go from the operator."})]})]}):e.jsxs("aside",{className:`${ye} sm:w-44`,children:[e.jsx("button",{type:"button",onClick:()=>h(!0),"aria-expanded":!1,className:"text-label uppercase tracking-wider text-fg-muted hover:text-fg focus-mark rounded-sm",children:"▸ Agents"}),e.jsx("div",{className:"mt-4 text-label uppercase tracking-wider text-fg-faint",children:m?"Reading as":e.jsx("span",{className:"text-accent",children:"▲ Reading as"})}),e.jsx("div",{className:`mt-1 text-body truncate ${m?"text-fg":"text-accent font-medium"}`,title:A,children:A}),!m&&e.jsxs(e.Fragment,{children:[e.jsx("button",{type:"button",onClick:l,className:"mt-3 block text-label uppercase tracking-wider text-fg-muted hover:text-fg focus-mark underline decoration-dotted underline-offset-2 rounded-sm",children:"Back to operator"}),e.jsx("p",{className:"mt-2 text-label uppercase tracking-wider text-fg-faint italic",children:"Read-only. Sends go from the operator."})]})]})}function ft(t){let a=0;for(const i of t)if(a+=i.aliases.length,a>1)return!1;return a<=1}async function pt(t,a){await F().sendMail(B("send supervisor mail"),{...t,from:a})}async function ve(t){await F().markMailRead(B("mark supervisor mail read"),t.id,U(t))}async function we(t){await F().markMailUnread(B("mark supervisor mail unread"),t.id,U(t))}async function xt(t){await F().archiveMail(B("archive supervisor mail"),t.id,U(t))}async function ht(t,a,i){await F().replyMail(B("reply supervisor mail"),t.id,{...a,from:i},U(t))}function U(t){return t.rig===void 0||t.rig.length===0?void 0:{rig:t.rig}}function gt({open:t,onClose:a,onSent:i}){const{viewingAs:c}=Ce(),n=Me(),{operatorAlias:l,operatorWireAlias:m}=re(),[b,h]=r.useState(""),[d,S]=r.useState(""),[y,u]=r.useState(""),[A,x]=r.useState(!1),[R,f]=r.useState(null);r.useEffect(()=>{t||(h(""),S(""),u(""),f(null))},[t]);const g=r.useCallback(async()=>{if(!n){x(!0),f(null);try{await pt({to:b,subject:d,body:y},m),i()}catch(N){f(ae(N,"send failed"))}finally{x(!1)}}},[y,i,n,d,b,m]),v=!n&&c.isOperator&&b.length>0&&d.length>0&&y.length>0&&!A;return e.jsx(_e,{open:t,onClose:a,title:"New message",caption:"Sends from the operator. Reading-as has no effect on the sender.",widthClass:"max-w-2xl",footer:e.jsxs(e.Fragment,{children:[e.jsx(M,{tone:"quiet",size:"sm",onClick:a,children:"Cancel"}),e.jsx(M,{tone:"accent",size:"sm",disabled:!v,title:n?_:void 0,onClick:()=>{g()},children:A?"Sending":"Send"})]}),children:e.jsxs("div",{className:"space-y-4",children:[e.jsx(P,{label:"From",variant:"form",children:e.jsx("input",{type:"text",value:c.isOperator?L(l,l):`${L(l,l)} (reading-as does not change sender)`,disabled:!0,className:"w-full bg-transparent border-0 border-b border-rule pb-1 text-body text-fg-muted italic"})}),e.jsx(P,{label:"To (alias)",variant:"form",children:e.jsx("input",{type:"text",autoFocus:!0,value:b,onChange:N=>h(N.target.value),placeholder:"mayor, mechanic, scix-worker, …",className:"w-full bg-transparent border-0 border-b border-rule pb-1 text-body text-fg placeholder:text-fg-faint focus:border-accent focus:outline-none transition-colors"})}),e.jsx(P,{label:"Subject",variant:"form",children:e.jsx("input",{type:"text",value:d,onChange:N=>S(N.target.value),maxLength:200,className:"w-full bg-transparent border-0 border-b border-rule pb-1 text-body text-fg focus:border-accent focus:outline-none transition-colors"})}),e.jsx(P,{label:"Body",variant:"form",children:e.jsx("textarea",{value:y,onChange:N=>u(N.target.value),rows:10,maxLength:16*1024,className:"w-full bg-surface-tint border border-rule rounded-sm px-3 py-2 text-body text-fg focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40 resize-y"})}),n&&e.jsx(ne,{}),!c.isOperator&&e.jsx(se,{tone:"warn",label:`Reading as ${L(c.alias,l)}. Sends from this modal are structurally locked to the operator regardless.`}),R&&e.jsx(se,{tone:"stuck",label:R})]})})}function Ne({message:t,attentionSeverity:a=null}){return e.jsxs("article",{...bt(a),className:"space-y-3 pb-4 border-b border-rule last:border-0",children:[e.jsxs("header",{className:"flex items-baseline justify-between gap-3",children:[e.jsxs("div",{className:"text-label uppercase tracking-wider text-fg-muted truncate",children:[e.jsx("span",{className:"text-fg font-medium",children:q(t.from)}),e.jsx("span",{className:"mx-1.5 text-fg-faint",children:"→"}),e.jsx("span",{children:q(t.to)})]}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:jt(t.created_at)})]}),e.jsx("p",{className:"text-title font-semibold text-fg",children:t.subject}),e.jsx(se,{tone:"warn",label:nt}),e.jsx("pre",{className:"text-body whitespace-pre-wrap leading-relaxed text-fg overflow-x-auto",children:t.body})]})}function bt(t){return t===null?{}:{"data-attention-severity":t}}function jt(t){const a=Date.parse(t);return Number.isFinite(a)?new Date(a).toLocaleString(void 0,{month:"short",day:"numeric",hour:"numeric",minute:"2-digit"}):"·"}const ke=[{id:"unread",label:"unread",match:t=>!t.read},{id:"read",label:"read",match:t=>t.read}],yt={id:"needs-you",label:"needs you",match:t=>!t.read&&!Je(t.from)},vt=t=>[t.from,t.to,t.subject,t.rig,t.body.split(` `)[0]],wt=1e3;function Ft(){const t=Ue(),a=Me(),i=re(),[c]=Ye(),n=At(c.get("message")),{viewingAs:l,setAlias:m,resetToOperator:b,aliasBuckets:h,aliasesLoading:d,sessionsUnavailable:S,loadAliases:y}=Ce(),[u,A]=r.useState(()=>n===null?"inbox":"all"),[x,R]=r.useState(()=>n===null?Ae:wt),[f,g]=r.useState(Le);r.useEffect(()=>{y()},[y]);const v=Ke(),{data:N,loading:le,error:Y,refresh:O}=Ve(`mail:${u}:${l.alias}:${i.operatorWireAlias}:${x}:${f}`,()=>Ge(u,l.alias,i,x,f,v)),j=r.useMemo(()=>N?.items??[],[N]),[ie,I]=r.useState(null);r.useEffect(()=>{Y&&I(Y)},[Y]);const[w,T]=r.useState(null),[K,W]=r.useState([]),[$e,oe]=r.useState(!1),V=r.useRef(null),[H,G]=r.useState(""),[E,ce]=r.useState(null),[Te,Q]=r.useState(!1),[$,z]=r.useState(()=>new Set),[Ee,de]=r.useState(null),J=r.useCallback(async s=>{if(T(s),W([]),G(""),I(null),!!s.thread_id){oe(!0);try{const o=await be(s.thread_id,l.alias,i,x);W(o.items)}catch(o){I(o instanceof Error?o.message:"thread failed")}finally{oe(!1)}}},[x,l.alias,i]);r.useEffect(()=>{if(n===null){V.current=null;return}if(V.current===n)return;const s=j.find(o=>o.id===n);s!==void 0&&(V.current=n,J(s))},[j,J,n]);const X=r.useCallback(async s=>{const o=w;if(o!==null&&!a){ce(s),I(null);try{if(s==="read")await ve(o),T({...o,read:!0});else if(s==="unread")await we(o),T({...o,read:!1});else if(s==="archive")await xt(o),T(null),W([]);else{const p=H.trim();if(p.length===0)return;if(await ht(o,{body:p},i.operatorWireAlias),G(""),o.thread_id){const De=await be(o.thread_id,l.alias,i,x);W(De.items)}}await O()}catch(p){I(ae(p,`${s} failed`))}finally{ce(null)}}},[x,a,O,H,w,l.alias,i]),ue=r.useMemo(()=>[{key:"from",label:"From",sortable:!0,sortValue:s=>q(s.from),render:s=>e.jsx("span",{className:"text-fg-muted",children:q(s.from)}),className:"w-48"},{key:"subject",label:"Subject",sortable:!0,sortValue:s=>s.subject,render:s=>e.jsxs("div",{className:"min-w-0",children:[e.jsx("p",{className:`truncate ${s.read?"text-fg-muted":"text-fg font-medium"}`,children:s.subject}),e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint mt-1 truncate",children:s.body.split(` `)[0]??""})]})},{key:"created_at",label:"When",sortable:!0,sortValue:s=>s.created_at,render:s=>e.jsx("span",{className:"tnum text-fg-muted",children:it(s.created_at,v)}),className:"w-24",align:"right"}],[v]),D=r.useMemo(()=>L(l.alias,i.operatorAlias),[l.alias,i.operatorAlias]),Z=r.useMemo(()=>u==="inbox"&&l.isOperator?Qe(j).length:0,[u,j,l.isOperator]),Pe=r.useMemo(()=>{const s=u==="all"?"all mail":u==="inbox"?"inbox":"sent";if(j.length===0)return`${Oe(s)} empty for ${D}.`;const o=u==="sent"?0:j.filter(p=>!p.read).length;return u==="inbox"&&l.isOperator?o===0?`${j.length} in inbox, all read.`:Z>0?`${j.length} in inbox, ${Z} need you of ${o} unread.`:`${j.length} in inbox, ${o} unread, none need you.`:o>0?`${j.length} in ${s}, ${o} unread.`:`${j.length} in ${s}.`},[u,j,D,Z,l.isOperator]),me=r.useMemo(()=>l.isOperator?[yt,...ke]:ke,[l.isOperator]),k=at({viewKey:`mail:${u}`,rows:j,projectOf:et,searchOf:vt,chips:me}),fe=u!=="sent",C=r.useMemo(()=>k.groups.flatMap(s=>s.rows),[k.groups]),pe=r.useMemo(()=>C.reduce((s,o)=>$.has(o.id)?s+1:s,0),[C,$]),ee=C.length>0&&pe===C.length;r.useEffect(()=>{z(new Set)},[u,l.alias]);const xe=r.useCallback(s=>{z(o=>{const p=new Set(o);return p.has(s)?p.delete(s):p.add(s),p})},[]),Fe=r.useCallback(()=>{z(ee?new Set:new Set(C.map(s=>s.id)))},[ee,C]),he=r.useCallback(async s=>{if(a)return;const o=C.filter(p=>$.has(p.id)&&p.read!==s);if(o.length!==0){de(s?"read":"unread"),I(null);try{await Promise.all(o.map(p=>s?ve(p):we(p))),z(new Set)}catch(p){I(ae(p,`bulk mark ${s?"read":"unread"} failed`))}finally{de(null),await O()}}},[a,C,$,O]),Be=r.useMemo(()=>({key:"__select",label:"",className:"w-8",render:s=>e.jsx("input",{type:"checkbox",className:"h-3.5 w-3.5 translate-y-[2px] cursor-pointer accent-fg focus-mark",checked:$.has(s.id),onChange:()=>xe(s.id),onClick:o=>o.stopPropagation(),"aria-label":`select mail: ${s.subject}`})}),[$,xe]),We=fe?[Be,...ue]:ue,He=r.useMemo(()=>s=>tt(je(t,"mail",s.id)),[t]),ge=r.useCallback(s=>je(t,"mail",s.id),[t]),te=u==="sent"?[]:me,ze=a||w===null||H.trim().length===0||E!==null||!l.isOperator;return e.jsxs("section",{children:[e.jsx(lt,{title:"Mail",synopsis:Pe,meta:e.jsxs(e.Fragment,{children:[ie&&e.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:ie}),a&&e.jsx(ne,{}),e.jsx(M,{size:"sm",onClick:()=>Q(!0),disabled:a||!l.isOperator,title:a?_:l.isOperator?"Compose a new message (sends as the operator)":"Switch back to the operator to compose",children:"Compose"}),e.jsx(M,{size:"sm",onClick:()=>{O()},disabled:le,children:le?"Refreshing":"Refresh"})]})}),e.jsxs("div",{className:"flex flex-col gap-8 sm:flex-row sm:items-start",children:[e.jsx(mt,{buckets:h,loading:d,sessionsUnavailable:S,value:l.alias,onChange:m,onReset:b,isOperator:l.isOperator}),e.jsxs("div",{className:"flex-1 min-w-0",children:[e.jsx("div",{className:"mb-6",children:e.jsx(Nt,{box:u,onChange:A})}),e.jsxs("div",{className:"mb-6 space-y-3",children:[e.jsx(Ze,{value:k.search,onChange:k.setSearch,placeholder:"Search mail by sender, subject, rig",matchCount:k.totalMatches,totalCount:j.length,ariaLabel:"Search mail"}),te.length>0&&e.jsxs("div",{className:"flex items-baseline justify-between gap-4 flex-wrap",children:[e.jsx(st,{chips:te,activeIds:k.activeChipIds,onToggle:k.toggleChip,legend:"Read state"}),e.jsx(Se,{limit:x,onLimitChange:R,onWindowChange:g,window:f})]}),te.length===0&&e.jsx("div",{className:"flex justify-end",children:e.jsx(Se,{limit:x,onLimitChange:R,onWindowChange:g,window:f})})]}),fe&&C.length>0&&e.jsx("div",{className:"mb-6",children:e.jsx(kt,{selectedCount:pe,allSelected:ee,onToggleAll:Fe,onMarkRead:()=>{he(!0)},onMarkUnread:()=>{he(!1)},bulkInFlight:Ee,readOnly:a})}),e.jsx(ut,{groups:k.groups,columns:We,rowKey:s=>s.id,onToggleProject:k.toggleProject,onRowClick:s=>{J(s)},rowProps:He,emptyMessage:k.search.length>0||k.activeChipIds.size>0?"No messages match the current search or filter.":`${u==="inbox"?"Inbox":"Sent"} empty for ${D}.`,perProjectEmpty:"No messages in this project.",initialSort:{key:"created_at",dir:"desc"}})]})]}),e.jsx(_e,{open:w!==null,onClose:()=>T(null),title:w?.subject??"Thread",caption:`Reading as ${D}, ${K.length} message(s)`,widthClass:"max-w-3xl",footer:w===null?null:e.jsxs(e.Fragment,{children:[e.jsx(M,{tone:"quiet",size:"sm",title:a?_:void 0,disabled:a||E!==null,onClick:()=>{X(w.read?"unread":"read")},children:w.read?"Mark unread":"Mark read"}),e.jsx(M,{tone:"quiet",size:"sm",title:a?_:void 0,disabled:a||E!==null,onClick:()=>{X("archive")},children:E==="archive"?"Archiving":"Archive"}),e.jsx(M,{tone:"accent",size:"sm",title:a?_:void 0,disabled:ze,onClick:()=>{X("reply")},children:E==="reply"?"Replying":"Reply"})]}),children:e.jsxs("div",{className:"space-y-6",children:[$e?e.jsx("p",{className:"text-fg-muted italic",children:"Loading thread."}):K.length===0&&w?e.jsx(Ne,{message:w,attentionSeverity:ge(w)}):e.jsx("ol",{className:"space-y-6",children:K.map(s=>e.jsx("li",{children:e.jsx(Ne,{message:s,attentionSeverity:ge(s)})},s.id))}),w!==null&&e.jsx(P,{label:"Reply",variant:"form",children:e.jsx("textarea",{value:H,onChange:s=>G(s.target.value),rows:5,maxLength:16*1024,title:a?_:void 0,disabled:a||!l.isOperator,className:"w-full bg-surface-tint border border-rule rounded-sm px-3 py-2 text-body text-fg focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40 resize-y disabled:opacity-50"})})]})}),e.jsx(gt,{open:Te,onClose:()=>Q(!1),onSent:()=>{Q(!1),u==="sent"&&O()}})]})}function Nt({box:t,onChange:a}){return e.jsx("div",{className:"flex items-baseline gap-6",children:["inbox","sent","all"].map(i=>e.jsx("button",{type:"button",onClick:()=>a(i),className:`text-title transition-colors duration-150 ease-out-quart focus-mark rounded-sm ${t===i?"text-fg font-semibold":"text-fg-muted hover:text-fg"}`,children:i==="all"?"All":Oe(i)},i))})}function kt({selectedCount:t,allSelected:a,onToggleAll:i,onMarkRead:c,onMarkUnread:n,bulkInFlight:l,readOnly:m}){const b=r.useRef(null),h=t>0;r.useEffect(()=>{b.current!==null&&(b.current.indeterminate=h&&!a)},[h,a]);const d=l!==null,S=m?_:void 0;return e.jsxs("div",{className:"flex items-baseline justify-between gap-4 flex-wrap border-b border-rule pb-3",role:"region","aria-label":"bulk mail selection",children:[e.jsxs("label",{className:"flex items-baseline gap-2 text-label uppercase tracking-wider text-fg-muted cursor-pointer",children:[e.jsx("input",{ref:b,type:"checkbox",className:"h-3.5 w-3.5 translate-y-[2px] cursor-pointer accent-fg focus-mark",checked:a,onChange:i,"aria-label":"select all mail"}),e.jsx("span",{children:h?`${t} selected`:"Select all"})]}),h&&e.jsxs("div",{className:"flex items-baseline gap-3",children:[m&&e.jsx(ne,{}),e.jsx(M,{size:"sm",tone:"quiet",onClick:c,disabled:m||d,title:S,children:l==="read"?"Marking":"Mark read"}),e.jsx(M,{size:"sm",tone:"quiet",onClick:n,disabled:m||d,title:S,children:l==="unread"?"Marking":"Mark unread"})]})]})}function Se({limit:t,onLimitChange:a,onWindowChange:i,window:c}){return e.jsxs("div",{className:"flex items-baseline gap-3 flex-wrap",children:[e.jsxs("label",{className:"flex items-baseline gap-2 text-label uppercase tracking-wider text-fg-muted",children:[e.jsx("span",{children:"Window"}),e.jsx("select",{"aria-label":"Mail time window",value:c,onChange:n=>i(Ct(n.target.value)),className:"bg-transparent border border-rule rounded-sm px-2 py-1 text-label uppercase tracking-wider text-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40",children:Re.map(n=>e.jsx("option",{value:n,children:Mt(n)},n))})]}),e.jsxs("label",{className:"flex items-baseline gap-2 text-label uppercase tracking-wider text-fg-muted",children:[e.jsx("span",{children:"History"}),e.jsx("select",{"aria-label":"Mail history limit",value:t,onChange:n=>a(St(n.target.value)),className:"bg-transparent border border-rule rounded-sm px-2 py-1 text-label uppercase tracking-wider text-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40",children:Ie.map(n=>e.jsxs("option",{value:n,children:["Recent ",n]},n))})]})]})}function St(t){const a=Number(t);return Ie.includes(a)?a:Ae}function Ct(t){return Re.includes(t)?t:Le}function Mt(t){return t==="24h"?"Last 24h":t==="7d"?"Last 7d":"All time"}function At(t){const a=t?.trim();return a&&a.length>0?a:null}function Oe(t){return t.charAt(0).toUpperCase()+t.slice(1)}export{Ft as MailPage}; diff --git a/internal/api/dashboardspa/dist/assets/PageHeader-5RHLpIfH.js b/internal/api/dashboardspa/dist/assets/PageHeader-D_D-jYn1.js similarity index 89% rename from internal/api/dashboardspa/dist/assets/PageHeader-5RHLpIfH.js rename to internal/api/dashboardspa/dist/assets/PageHeader-D_D-jYn1.js index bb789594f5..ce721feec6 100644 --- a/internal/api/dashboardspa/dist/assets/PageHeader-5RHLpIfH.js +++ b/internal/api/dashboardspa/dist/assets/PageHeader-D_D-jYn1.js @@ -1 +1 @@ -import{j as e}from"./index-BFDP6Xwd.js";function d({title:t,synopsis:s,meta:a,className:r=""}){return e.jsxs("header",{className:`grid grid-cols-1 items-start gap-x-6 gap-y-4 mb-10 md:grid-cols-[minmax(0,1fr)_auto] md:items-end ${r}`,children:[e.jsxs("div",{className:"min-w-0 space-y-2",children:[e.jsx("h1",{className:"text-display font-semibold tracking-tighter text-fg leading-[1.05]",children:t}),s&&e.jsx("p",{className:"text-body text-fg-muted max-w-prose",children:s})]}),a&&e.jsx("div",{className:"flex flex-wrap items-center gap-4 text-label uppercase tracking-wider md:justify-end",children:a})]})}export{d as P}; +import{j as e}from"./index-C20tCZFz.js";function d({title:t,synopsis:s,meta:a,className:r=""}){return e.jsxs("header",{className:`grid grid-cols-1 items-start gap-x-6 gap-y-4 mb-10 md:grid-cols-[minmax(0,1fr)_auto] md:items-end ${r}`,children:[e.jsxs("div",{className:"min-w-0 space-y-2",children:[e.jsx("h1",{className:"text-display font-semibold tracking-tighter text-fg leading-[1.05]",children:t}),s&&e.jsx("p",{className:"text-body text-fg-muted max-w-prose",children:s})]}),a&&e.jsx("div",{className:"flex flex-wrap items-center gap-4 text-label uppercase tracking-wider md:justify-end",children:a})]})}export{d as P}; diff --git a/internal/api/dashboardspa/dist/assets/Runs-DlWanzbB.js b/internal/api/dashboardspa/dist/assets/Runs-BCTFHOlQ.js similarity index 98% rename from internal/api/dashboardspa/dist/assets/Runs-DlWanzbB.js rename to internal/api/dashboardspa/dist/assets/Runs-BCTFHOlQ.js index 8d3e1985f5..a9634b59ea 100644 --- a/internal/api/dashboardspa/dist/assets/Runs-DlWanzbB.js +++ b/internal/api/dashboardspa/dist/assets/Runs-BCTFHOlQ.js @@ -1 +1 @@ -import{j as e,L as B,a6 as O,r as x,a7 as D,a as M,a8 as U,K as z,u as V,B as w}from"./index-BFDP6Xwd.js";import{b as F,r as Y}from"./routeHighlight-B30gQO2o.js";import{P as K}from"./PageHeader-5RHLpIfH.js";import{S as Q,P as q}from"./SseIndicator-DGo-aCtn.js";import{f as _}from"./time-D9v0saHV.js";import{S as G}from"./StageLadder-BIFUAoAh.js";const f=8;function W(t){return t==="blocked"?"text-accent":t==="complete"?"text-fg-muted":"text-fg"}function I({lane:t,now:n,attentionSeverity:r=null,blocked:s}){const a=Object.entries(t.statusCounts).sort((l,c)=>k(l[0]).localeCompare(k(c[0]))),{className:i="",...d}=F(r);return e.jsxs("li",{...d,className:`py-4 transition-colors duration-150 ease-out-quart ${i}`,children:[e.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[e.jsx("span",{className:`text-label uppercase tracking-wider ${W(t.phase)}`,children:t.phaseLabel}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum tabular-nums",title:t.updatedAt.status==="available"?t.updatedAt.at:t.updatedAt.error,children:t.updatedAt.status==="available"?_(t.updatedAt.at,n):"·"})]}),e.jsx(B,{to:O(t.id,t.scope),className:"focus-mark mt-1 block text-body text-fg leading-snug hover:text-accent",children:t.title}),(t.external.status!=="unavailable"||t.formula.status==="known")&&e.jsxs("div",{className:"mt-1 flex items-baseline gap-x-4 gap-y-1 flex-wrap text-label",children:[t.external.status!=="unavailable"&&(t.external.status==="available"?e.jsx("a",{href:t.external.url,target:"_blank",rel:"noreferrer",className:"text-fg-muted uppercase tracking-wider hover:text-fg focus-mark",children:t.external.label}):e.jsx("span",{className:"text-fg-muted uppercase tracking-wider",children:t.external.label})),t.formula.status==="known"&&e.jsx("span",{className:"text-fg-faint tnum",children:t.formula.name})]}),e.jsx(G,{stages:t.stages,label:t.title}),e.jsxs("div",{className:"mt-2 flex items-baseline gap-x-4 gap-y-1 flex-wrap text-label",children:[e.jsx("span",{className:"text-fg-faint tnum",title:"run root bead",children:t.id}),t.activeAssignees.length>0&&e.jsxs("span",{className:"text-fg-muted lowercase tracking-normal",children:[e.jsx("span",{className:"uppercase tracking-wider text-fg-faint",children:"on "}),t.activeAssignees.join(", ")]}),a.length>0&&e.jsx("span",{className:"text-fg-faint uppercase tracking-wider tnum tabular-nums",children:a.map(([l,c])=>`${c} ${l.replace(/_/g," ")}`).join(" · ")})]}),s!==void 0&&e.jsxs("div",{className:"mt-2",children:[e.jsxs("p",{className:"text-body text-fg leading-snug",children:[e.jsx("span",{"aria-hidden":"true",className:"text-accent",children:"✕"})," ",s.reason]}),e.jsx("p",{className:"mt-1 text-body text-fg-muted leading-snug",children:s.remedy})]})]})}function k(t){return`${{blocked:"0",in_progress:"1",open:"2",closed:"3"}[t]??"9"}-${t}`}const X=[["prReview","PR"],["designReview","Design"],["bugfix","Bugfix"],["other","Other"]],C="runs-historical-section",S="runs-historical-list",R="runs-active-list",h=5;function J({source:t,now:n,showHistory:r,attentionSeverity:s}){if(t.status==="error")return e.jsxs("section",{children:[e.jsx(A,{summary:null}),e.jsx("p",{className:"mt-8 text-body text-fg-muted italic",children:`Run data unavailable: ${t.error}.`})]});const a=t.data;return e.jsxs("section",{children:[e.jsx(A,{summary:a}),e.jsx(Z,{summary:a,now:n,...s===void 0?{}:{attentionSeverity:s}}),e.jsx(se,{summary:a,now:n,...s===void 0?{}:{attentionSeverity:s}}),r&&e.jsx(ae,{summary:a,now:n,...s===void 0?{}:{attentionSeverity:s}})]})}function Z({summary:t,now:n,attentionSeverity:r}){const[s,a]=x.useState(!1);if(t.lanes.length===0){if(t.lanesPartial===!0)return e.jsx("p",{className:"mt-8 text-body text-fg-muted italic",children:"Run sources were partially unavailable; the lane set may be incomplete."});const l=t.totalHistorical>0?` (${t.totalHistorical} completed.)`:"";return e.jsx("p",{className:"mt-8 text-body text-fg-muted italic",children:`No active formula runs.${l}`})}const i=s?t.lanes:t.lanes.slice(0,f),d=ee(i);return e.jsxs(e.Fragment,{children:[e.jsx("div",{id:R,children:d.map(({rig:l,lanes:c})=>e.jsxs("div",{className:"mt-6",children:[e.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint",children:te(l)}),e.jsx(H,{lanes:c,now:n,...r===void 0?{}:{attentionSeverity:r}})]},l))}),t.lanes.length>f&&e.jsx("button",{type:"button",onClick:()=>a(l=>!l),"aria-expanded":s,"aria-controls":R,className:"mt-3 text-label uppercase tracking-wider text-fg-faint tnum hover:text-fg focus-mark",children:s?"Show fewer":`Show ${t.lanes.length-f} more runs`})]})}function H({lanes:t,now:n,attentionSeverity:r,listId:s}){return e.jsx("ol",{...s===void 0?{}:{id:s},className:"mt-3 divide-y divide-rule",children:t.map(a=>e.jsx(I,{lane:a,now:n,...r===void 0?{}:{attentionSeverity:r(a)}},a.id))})}function ee(t){const n=[],r=new Map;for(const s of t){const a=s.scope.status==="available"&&s.scope.kind==="rig"?s.scope.rootStoreRef:"city";let i=r.get(a);i===void 0&&(i=[],r.set(a,i),n.push(a)),i.push(s)}return n.map(s=>({rig:s,lanes:r.get(s)}))}function te(t){return t.replace(/^rig:/,"")}function se({summary:t,now:n,attentionSeverity:r}){const s=new Map(D(t.blockedLanes).map(a=>[a.id,a]));return s.size===0?null:e.jsxs("section",{"aria-label":"Blocked runs",className:"mt-12",children:[e.jsxs("h2",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:["Blocked (",s.size,")"]}),e.jsx("ol",{className:"mt-3 divide-y divide-rule",children:t.blockedLanes.map(a=>{const i=s.get(a.id);return e.jsx(I,{lane:a,now:n,...r===void 0?{}:{attentionSeverity:r(a)},...i===void 0?{}:{blocked:i}},a.id)})})]})}function ae({summary:t,now:n,attentionSeverity:r}){const[s,a]=x.useState(!1),i=t.historicalLanes,d=s?i:i.slice(0,h);return e.jsxs("section",{id:C,"aria-label":"Historical runs",className:"mt-12",children:[e.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Historical"}),i.length===0?e.jsx("p",{className:"mt-3 text-body text-fg-muted italic",children:"No completed runs in the current window."}):e.jsxs(e.Fragment,{children:[e.jsx(H,{lanes:d,now:n,listId:S,...r===void 0?{}:{attentionSeverity:r}}),i.length>h&&e.jsx("button",{type:"button",onClick:()=>a(l=>!l),"aria-expanded":s,"aria-controls":S,className:"mt-3 text-label uppercase tracking-wider text-fg-faint tnum hover:text-fg focus-mark",children:s?"Show fewer":`Show ${i.length-h} more`}),t.totalHistorical>i.length&&e.jsxs("p",{className:"mt-3 text-label uppercase tracking-wider text-fg-faint tnum",children:["Showing ",i.length," most-recent of ",t.totalHistorical]})]})]})}function A({summary:t}){const n=t?.runCounts.total??0,r=t?.runCounts.blocked??0;return e.jsx("header",{className:"space-y-2",children:e.jsxs("div",{className:"flex items-baseline gap-x-6 gap-y-2 flex-wrap",children:[e.jsx(g,{label:"Active",value:n,tone:"strong"}),X.map(([s,a])=>e.jsx(g,{label:a,value:t?.runCounts[s]??0,tone:"muted"},s)),r>0&&e.jsx(g,{label:"Blocked",value:r,tone:"muted"})]})})}function g({label:t,value:n,tone:r}){const s=r==="strong"?"text-fg":"text-fg-muted";return e.jsxs("div",{className:"flex flex-col",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint",children:t}),e.jsx("span",{className:`text-title tnum ${s}`,children:n})]})}const re=C,L="Phase grammar: intake, implementation, review, approval, finalization.",b="history",y="1";function xe(){const t=M(),{source:n,loading:r,error:s,refresh:a,sseState:i}=U(),[d,l]=z(),c=d.get(b)===y,j=V(),o=n??null,N=o?.status==="fresh"||o?.status==="fixture"||o?.status==="stale"?o.data:null,u=N?.totalHistorical??0,$=N?.lanesPartial===!0,P=x.useCallback(()=>{l(m=>{const p=new URLSearchParams(m);return c?p.delete(b):p.set(b,y),p},{replace:!1})},[c,l]),E=x.useCallback(m=>Y(t,"runs",m.id),[t]),T=ne(n),v=o?o.status==="fresh"?null:o.status==="fixture"?"fixture data":o.status==="error"?"live data unavailable":o.fetchedAt?`stale ${_(o.fetchedAt,j)} ago`:"stale":null;return e.jsxs("section",{children:[e.jsx(K,{title:"Formula Runs",synopsis:T,className:"md:items-start",meta:e.jsxs(e.Fragment,{children:[s&&e.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:s}),v!==null&&e.jsx("span",{className:`text-label uppercase tracking-wider tnum ${o?.status==="error"?"text-accent":"text-fg-faint"}`,children:v}),e.jsxs("div",{className:"grid w-full min-w-[18rem] grid-cols-[7rem_minmax(6.5rem,1fr)] items-center gap-x-4 gap-y-3 sm:w-[34rem] sm:grid-cols-[7rem_6.5rem_10rem_7rem]",children:[e.jsx(Q,{state:i}),e.jsx("span",{children:$?e.jsx(q,{glyph:"◐",label:"runs partial",title:"one or more rigs' recent runs were unavailable; the lane set may be incomplete"}):e.jsx("span",{"aria-hidden":"true",className:"invisible normal-case text-body text-warn",children:"runs partial"})}),e.jsx(w,{size:"sm",className:"w-full justify-center",onClick:P,disabled:!c&&u===0,"aria-expanded":c,...c?{"aria-controls":re}:{},"aria-label":c?"Hide historical formula runs.":u===0?"No completed formula runs in the current window.":`Show ${u} completed formula runs.`,children:c?"Hide history":u>0?`Show history (${u})`:"Show history"}),e.jsx(w,{size:"sm",className:"w-full justify-center",onClick:()=>{a()},disabled:r,children:r?"Refreshing":"Refresh"})]})]})}),n===void 0||o===null?e.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading formula runs."}):e.jsx(J,{source:o,now:j,showHistory:c,attentionSeverity:E})]})}function ne(t){return t===void 0?"Loading formula run lanes.":t.status!=="error"?`${t.data.totalActive} active runs across the supervisor's bead store. ${L}`:`Run counts unavailable: ${t.error}. ${L}`}export{xe as RunsPage}; +import{j as e,L as B,a6 as O,r as x,a7 as D,a as M,a8 as U,K as z,u as V,B as w}from"./index-C20tCZFz.js";import{b as F,r as Y}from"./routeHighlight-B30gQO2o.js";import{P as K}from"./PageHeader-D_D-jYn1.js";import{S as Q,P as q}from"./SseIndicator-CeTTAF2S.js";import{f as _}from"./time-D9v0saHV.js";import{S as G}from"./StageLadder-B3yZa5o4.js";const f=8;function W(t){return t==="blocked"?"text-accent":t==="complete"?"text-fg-muted":"text-fg"}function I({lane:t,now:n,attentionSeverity:r=null,blocked:s}){const a=Object.entries(t.statusCounts).sort((l,c)=>k(l[0]).localeCompare(k(c[0]))),{className:i="",...d}=F(r);return e.jsxs("li",{...d,className:`py-4 transition-colors duration-150 ease-out-quart ${i}`,children:[e.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[e.jsx("span",{className:`text-label uppercase tracking-wider ${W(t.phase)}`,children:t.phaseLabel}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum tabular-nums",title:t.updatedAt.status==="available"?t.updatedAt.at:t.updatedAt.error,children:t.updatedAt.status==="available"?_(t.updatedAt.at,n):"·"})]}),e.jsx(B,{to:O(t.id,t.scope),className:"focus-mark mt-1 block text-body text-fg leading-snug hover:text-accent",children:t.title}),(t.external.status!=="unavailable"||t.formula.status==="known")&&e.jsxs("div",{className:"mt-1 flex items-baseline gap-x-4 gap-y-1 flex-wrap text-label",children:[t.external.status!=="unavailable"&&(t.external.status==="available"?e.jsx("a",{href:t.external.url,target:"_blank",rel:"noreferrer",className:"text-fg-muted uppercase tracking-wider hover:text-fg focus-mark",children:t.external.label}):e.jsx("span",{className:"text-fg-muted uppercase tracking-wider",children:t.external.label})),t.formula.status==="known"&&e.jsx("span",{className:"text-fg-faint tnum",children:t.formula.name})]}),e.jsx(G,{stages:t.stages,label:t.title}),e.jsxs("div",{className:"mt-2 flex items-baseline gap-x-4 gap-y-1 flex-wrap text-label",children:[e.jsx("span",{className:"text-fg-faint tnum",title:"run root bead",children:t.id}),t.activeAssignees.length>0&&e.jsxs("span",{className:"text-fg-muted lowercase tracking-normal",children:[e.jsx("span",{className:"uppercase tracking-wider text-fg-faint",children:"on "}),t.activeAssignees.join(", ")]}),a.length>0&&e.jsx("span",{className:"text-fg-faint uppercase tracking-wider tnum tabular-nums",children:a.map(([l,c])=>`${c} ${l.replace(/_/g," ")}`).join(" · ")})]}),s!==void 0&&e.jsxs("div",{className:"mt-2",children:[e.jsxs("p",{className:"text-body text-fg leading-snug",children:[e.jsx("span",{"aria-hidden":"true",className:"text-accent",children:"✕"})," ",s.reason]}),e.jsx("p",{className:"mt-1 text-body text-fg-muted leading-snug",children:s.remedy})]})]})}function k(t){return`${{blocked:"0",in_progress:"1",open:"2",closed:"3"}[t]??"9"}-${t}`}const X=[["prReview","PR"],["designReview","Design"],["bugfix","Bugfix"],["other","Other"]],C="runs-historical-section",S="runs-historical-list",R="runs-active-list",h=5;function J({source:t,now:n,showHistory:r,attentionSeverity:s}){if(t.status==="error")return e.jsxs("section",{children:[e.jsx(A,{summary:null}),e.jsx("p",{className:"mt-8 text-body text-fg-muted italic",children:`Run data unavailable: ${t.error}.`})]});const a=t.data;return e.jsxs("section",{children:[e.jsx(A,{summary:a}),e.jsx(Z,{summary:a,now:n,...s===void 0?{}:{attentionSeverity:s}}),e.jsx(se,{summary:a,now:n,...s===void 0?{}:{attentionSeverity:s}}),r&&e.jsx(ae,{summary:a,now:n,...s===void 0?{}:{attentionSeverity:s}})]})}function Z({summary:t,now:n,attentionSeverity:r}){const[s,a]=x.useState(!1);if(t.lanes.length===0){if(t.lanesPartial===!0)return e.jsx("p",{className:"mt-8 text-body text-fg-muted italic",children:"Run sources were partially unavailable; the lane set may be incomplete."});const l=t.totalHistorical>0?` (${t.totalHistorical} completed.)`:"";return e.jsx("p",{className:"mt-8 text-body text-fg-muted italic",children:`No active formula runs.${l}`})}const i=s?t.lanes:t.lanes.slice(0,f),d=ee(i);return e.jsxs(e.Fragment,{children:[e.jsx("div",{id:R,children:d.map(({rig:l,lanes:c})=>e.jsxs("div",{className:"mt-6",children:[e.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint",children:te(l)}),e.jsx(H,{lanes:c,now:n,...r===void 0?{}:{attentionSeverity:r}})]},l))}),t.lanes.length>f&&e.jsx("button",{type:"button",onClick:()=>a(l=>!l),"aria-expanded":s,"aria-controls":R,className:"mt-3 text-label uppercase tracking-wider text-fg-faint tnum hover:text-fg focus-mark",children:s?"Show fewer":`Show ${t.lanes.length-f} more runs`})]})}function H({lanes:t,now:n,attentionSeverity:r,listId:s}){return e.jsx("ol",{...s===void 0?{}:{id:s},className:"mt-3 divide-y divide-rule",children:t.map(a=>e.jsx(I,{lane:a,now:n,...r===void 0?{}:{attentionSeverity:r(a)}},a.id))})}function ee(t){const n=[],r=new Map;for(const s of t){const a=s.scope.status==="available"&&s.scope.kind==="rig"?s.scope.rootStoreRef:"city";let i=r.get(a);i===void 0&&(i=[],r.set(a,i),n.push(a)),i.push(s)}return n.map(s=>({rig:s,lanes:r.get(s)}))}function te(t){return t.replace(/^rig:/,"")}function se({summary:t,now:n,attentionSeverity:r}){const s=new Map(D(t.blockedLanes).map(a=>[a.id,a]));return s.size===0?null:e.jsxs("section",{"aria-label":"Blocked runs",className:"mt-12",children:[e.jsxs("h2",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:["Blocked (",s.size,")"]}),e.jsx("ol",{className:"mt-3 divide-y divide-rule",children:t.blockedLanes.map(a=>{const i=s.get(a.id);return e.jsx(I,{lane:a,now:n,...r===void 0?{}:{attentionSeverity:r(a)},...i===void 0?{}:{blocked:i}},a.id)})})]})}function ae({summary:t,now:n,attentionSeverity:r}){const[s,a]=x.useState(!1),i=t.historicalLanes,d=s?i:i.slice(0,h);return e.jsxs("section",{id:C,"aria-label":"Historical runs",className:"mt-12",children:[e.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Historical"}),i.length===0?e.jsx("p",{className:"mt-3 text-body text-fg-muted italic",children:"No completed runs in the current window."}):e.jsxs(e.Fragment,{children:[e.jsx(H,{lanes:d,now:n,listId:S,...r===void 0?{}:{attentionSeverity:r}}),i.length>h&&e.jsx("button",{type:"button",onClick:()=>a(l=>!l),"aria-expanded":s,"aria-controls":S,className:"mt-3 text-label uppercase tracking-wider text-fg-faint tnum hover:text-fg focus-mark",children:s?"Show fewer":`Show ${i.length-h} more`}),t.totalHistorical>i.length&&e.jsxs("p",{className:"mt-3 text-label uppercase tracking-wider text-fg-faint tnum",children:["Showing ",i.length," most-recent of ",t.totalHistorical]})]})]})}function A({summary:t}){const n=t?.runCounts.total??0,r=t?.runCounts.blocked??0;return e.jsx("header",{className:"space-y-2",children:e.jsxs("div",{className:"flex items-baseline gap-x-6 gap-y-2 flex-wrap",children:[e.jsx(g,{label:"Active",value:n,tone:"strong"}),X.map(([s,a])=>e.jsx(g,{label:a,value:t?.runCounts[s]??0,tone:"muted"},s)),r>0&&e.jsx(g,{label:"Blocked",value:r,tone:"muted"})]})})}function g({label:t,value:n,tone:r}){const s=r==="strong"?"text-fg":"text-fg-muted";return e.jsxs("div",{className:"flex flex-col",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint",children:t}),e.jsx("span",{className:`text-title tnum ${s}`,children:n})]})}const re=C,L="Phase grammar: intake, implementation, review, approval, finalization.",b="history",y="1";function xe(){const t=M(),{source:n,loading:r,error:s,refresh:a,sseState:i}=U(),[d,l]=z(),c=d.get(b)===y,j=V(),o=n??null,N=o?.status==="fresh"||o?.status==="fixture"||o?.status==="stale"?o.data:null,u=N?.totalHistorical??0,$=N?.lanesPartial===!0,P=x.useCallback(()=>{l(m=>{const p=new URLSearchParams(m);return c?p.delete(b):p.set(b,y),p},{replace:!1})},[c,l]),E=x.useCallback(m=>Y(t,"runs",m.id),[t]),T=ne(n),v=o?o.status==="fresh"?null:o.status==="fixture"?"fixture data":o.status==="error"?"live data unavailable":o.fetchedAt?`stale ${_(o.fetchedAt,j)} ago`:"stale":null;return e.jsxs("section",{children:[e.jsx(K,{title:"Formula Runs",synopsis:T,className:"md:items-start",meta:e.jsxs(e.Fragment,{children:[s&&e.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:s}),v!==null&&e.jsx("span",{className:`text-label uppercase tracking-wider tnum ${o?.status==="error"?"text-accent":"text-fg-faint"}`,children:v}),e.jsxs("div",{className:"grid w-full min-w-[18rem] grid-cols-[7rem_minmax(6.5rem,1fr)] items-center gap-x-4 gap-y-3 sm:w-[34rem] sm:grid-cols-[7rem_6.5rem_10rem_7rem]",children:[e.jsx(Q,{state:i}),e.jsx("span",{children:$?e.jsx(q,{glyph:"◐",label:"runs partial",title:"one or more rigs' recent runs were unavailable; the lane set may be incomplete"}):e.jsx("span",{"aria-hidden":"true",className:"invisible normal-case text-body text-warn",children:"runs partial"})}),e.jsx(w,{size:"sm",className:"w-full justify-center",onClick:P,disabled:!c&&u===0,"aria-expanded":c,...c?{"aria-controls":re}:{},"aria-label":c?"Hide historical formula runs.":u===0?"No completed formula runs in the current window.":`Show ${u} completed formula runs.`,children:c?"Hide history":u>0?`Show history (${u})`:"Show history"}),e.jsx(w,{size:"sm",className:"w-full justify-center",onClick:()=>{a()},disabled:r,children:r?"Refreshing":"Refresh"})]})]})}),n===void 0||o===null?e.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading formula runs."}):e.jsx(J,{source:o,now:j,showHistory:c,attentionSeverity:E})]})}function ne(t){return t===void 0?"Loading formula run lanes.":t.status!=="error"?`${t.data.totalActive} active runs across the supervisor's bead store. ${L}`:`Run counts unavailable: ${t.error}. ${L}`}export{xe as RunsPage}; diff --git a/internal/api/dashboardspa/dist/assets/SseIndicator-DGo-aCtn.js b/internal/api/dashboardspa/dist/assets/SseIndicator-CeTTAF2S.js similarity index 88% rename from internal/api/dashboardspa/dist/assets/SseIndicator-DGo-aCtn.js rename to internal/api/dashboardspa/dist/assets/SseIndicator-CeTTAF2S.js index 71fa5c7a00..72f235e47d 100644 --- a/internal/api/dashboardspa/dist/assets/SseIndicator-DGo-aCtn.js +++ b/internal/api/dashboardspa/dist/assets/SseIndicator-CeTTAF2S.js @@ -1 +1 @@ -import{j as a,S as t}from"./index-BFDP6Xwd.js";function i({label:n,title:e,show:r=!0,glyph:o}){return r?a.jsxs("span",{className:"normal-case text-body text-warn",role:"status",title:e,children:[o!==void 0&&a.jsxs("span",{"aria-hidden":"true",children:[o," "]}),n]}):null}function c({state:n}){const e=n==="open"?"ok":n==="connecting"||n==="degraded"?"warn":"stuck",r=n==="open"?"live":n==="connecting"?"connecting":n==="degraded"?"degraded":"offline";return a.jsx(t,{tone:e,label:r,title:`SSE stream: ${n}`,className:"w-28"})}export{i as P,c as S}; +import{j as a,S as t}from"./index-C20tCZFz.js";function i({label:n,title:e,show:r=!0,glyph:o}){return r?a.jsxs("span",{className:"normal-case text-body text-warn",role:"status",title:e,children:[o!==void 0&&a.jsxs("span",{"aria-hidden":"true",children:[o," "]}),n]}):null}function c({state:n}){const e=n==="open"?"ok":n==="connecting"||n==="degraded"?"warn":"stuck",r=n==="open"?"live":n==="connecting"?"connecting":n==="degraded"?"degraded":"offline";return a.jsx(t,{tone:e,label:r,title:`SSE stream: ${n}`,className:"w-28"})}export{i as P,c as S}; diff --git a/internal/api/dashboardspa/dist/assets/StageLadder-BIFUAoAh.js b/internal/api/dashboardspa/dist/assets/StageLadder-B3yZa5o4.js similarity index 91% rename from internal/api/dashboardspa/dist/assets/StageLadder-BIFUAoAh.js rename to internal/api/dashboardspa/dist/assets/StageLadder-B3yZa5o4.js index 42f277072a..b84b993fb1 100644 --- a/internal/api/dashboardspa/dist/assets/StageLadder-BIFUAoAh.js +++ b/internal/api/dashboardspa/dist/assets/StageLadder-B3yZa5o4.js @@ -1 +1 @@ -import{j as t}from"./index-BFDP6Xwd.js";const n={pending:"·",active:"⬣",complete:"◆",blocked:"✕"},c={pending:"text-fg-faint",active:"text-fg",complete:"text-fg-muted",blocked:"text-accent"},s={pending:"text-fg-muted",active:"text-fg",complete:"text-fg-muted",blocked:"text-accent"};function r({stages:a,label:l}){return a.length===0?null:t.jsx("ol",{className:"mt-2 flex items-baseline gap-x-2 flex-wrap","aria-label":`${l} stages`,children:a.map(e=>t.jsxs("li",{className:`text-label uppercase tracking-wider ${c[e.status]}`,title:`${e.label}: ${e.status}`,children:[t.jsx("span",{"aria-hidden":"true",children:n[e.status]})," ",t.jsx("span",{className:s[e.status],children:e.label})]},e.key))})}export{r as S}; +import{j as t}from"./index-C20tCZFz.js";const n={pending:"·",active:"⬣",complete:"◆",blocked:"✕"},c={pending:"text-fg-faint",active:"text-fg",complete:"text-fg-muted",blocked:"text-accent"},s={pending:"text-fg-muted",active:"text-fg",complete:"text-fg-muted",blocked:"text-accent"};function r({stages:a,label:l}){return a.length===0?null:t.jsx("ol",{className:"mt-2 flex items-baseline gap-x-2 flex-wrap","aria-label":`${l} stages`,children:a.map(e=>t.jsxs("li",{className:`text-label uppercase tracking-wider ${c[e.status]}`,title:`${e.label}: ${e.status}`,children:[t.jsx("span",{"aria-hidden":"true",children:n[e.status]})," ",t.jsx("span",{className:s[e.status],children:e.label})]},e.key))})}export{r as S}; diff --git a/internal/api/dashboardspa/dist/assets/Table-3q0HSJQI.js b/internal/api/dashboardspa/dist/assets/Table-DojZJIvD.js similarity index 96% rename from internal/api/dashboardspa/dist/assets/Table-3q0HSJQI.js rename to internal/api/dashboardspa/dist/assets/Table-DojZJIvD.js index c9c62b27e2..237ee0e82d 100644 --- a/internal/api/dashboardspa/dist/assets/Table-3q0HSJQI.js +++ b/internal/api/dashboardspa/dist/assets/Table-DojZJIvD.js @@ -1 +1 @@ -import{r as x,j as t}from"./index-BFDP6Xwd.js";function y({columns:i,rows:d,rowKey:f,onRowClick:c,rowProps:h,empty:b,initialSort:g}){const[n,p]=x.useState(g??null),m=x.useMemo(()=>{if(n===null)return d;const e=i.find(r=>r.key===n.key);if(!e||!e.sortable)return d;const s=e.sortValue??(r=>String(e.render(r)??"")),a=n.dir==="asc"?1:-1;return[...d].sort((r,u)=>{const l=s(r),o=s(u);return l===o?0:l==null?-a:o==null?a:lo?a:0})},[d,i,n]),N=e=>{p(s=>s?.key!==e?{key:e,dir:"asc"}:{key:e,dir:s.dir==="asc"?"desc":"asc"})};return t.jsx("div",{className:"overflow-x-auto",children:t.jsxs("table",{className:"w-full text-body tnum",children:[t.jsx("thead",{children:t.jsx("tr",{className:"border-b border-rule text-label uppercase tracking-wider text-fg-muted",children:i.map(e=>{const s=n?.key===e.key,a=e.align==="right"?"text-right":"text-left";return t.jsx("th",{scope:"col",className:`pb-3 pr-6 font-medium select-none ${a} ${e.className??""}`,children:e.sortable?t.jsxs("button",{type:"button",onClick:()=>N(e.key),className:"inline-flex items-center gap-1 hover:text-fg transition-colors duration-150 ease-out-quart focus-mark rounded-sm",children:[e.label,s&&t.jsx("span",{"aria-hidden":!0,className:"text-accent",children:n?.dir==="asc"?"↑":"↓"})]}):e.label},e.key)})})}),t.jsx("tbody",{children:m.length===0?t.jsx("tr",{children:t.jsx("td",{colSpan:i.length,className:"py-10 text-center text-fg-muted italic",children:b??"No data"})}):m.map(e=>{const{className:s="",...a}=h?.(e)??{};return x.createElement("tr",{...a,key:f(e),onClick:c?()=>c(e):void 0,className:`border-b border-rule transition-colors duration-150 ease-out-quart ${c?"cursor-pointer hover:bg-surface-tint":""} ${s}`},i.map(r=>{const u=r.align==="right"?"text-right":"text-left";return t.jsx("td",{className:`py-3 pr-6 align-baseline ${u} ${r.className??""}`,children:r.render(e)},r.key)}))})})]})})}export{y as T}; +import{r as x,j as t}from"./index-C20tCZFz.js";function y({columns:i,rows:d,rowKey:f,onRowClick:c,rowProps:h,empty:b,initialSort:g}){const[n,p]=x.useState(g??null),m=x.useMemo(()=>{if(n===null)return d;const e=i.find(r=>r.key===n.key);if(!e||!e.sortable)return d;const s=e.sortValue??(r=>String(e.render(r)??"")),a=n.dir==="asc"?1:-1;return[...d].sort((r,u)=>{const l=s(r),o=s(u);return l===o?0:l==null?-a:o==null?a:lo?a:0})},[d,i,n]),N=e=>{p(s=>s?.key!==e?{key:e,dir:"asc"}:{key:e,dir:s.dir==="asc"?"desc":"asc"})};return t.jsx("div",{className:"overflow-x-auto",children:t.jsxs("table",{className:"w-full text-body tnum",children:[t.jsx("thead",{children:t.jsx("tr",{className:"border-b border-rule text-label uppercase tracking-wider text-fg-muted",children:i.map(e=>{const s=n?.key===e.key,a=e.align==="right"?"text-right":"text-left";return t.jsx("th",{scope:"col",className:`pb-3 pr-6 font-medium select-none ${a} ${e.className??""}`,children:e.sortable?t.jsxs("button",{type:"button",onClick:()=>N(e.key),className:"inline-flex items-center gap-1 hover:text-fg transition-colors duration-150 ease-out-quart focus-mark rounded-sm",children:[e.label,s&&t.jsx("span",{"aria-hidden":!0,className:"text-accent",children:n?.dir==="asc"?"↑":"↓"})]}):e.label},e.key)})})}),t.jsx("tbody",{children:m.length===0?t.jsx("tr",{children:t.jsx("td",{colSpan:i.length,className:"py-10 text-center text-fg-muted italic",children:b??"No data"})}):m.map(e=>{const{className:s="",...a}=h?.(e)??{};return x.createElement("tr",{...a,key:f(e),onClick:c?()=>c(e):void 0,className:`border-b border-rule transition-colors duration-150 ease-out-quart ${c?"cursor-pointer hover:bg-surface-tint":""} ${s}`},i.map(r=>{const u=r.align==="right"?"text-right":"text-left";return t.jsx("td",{className:`py-3 pr-6 align-baseline ${u} ${r.className??""}`,children:r.render(e)},r.key)}))})})]})})}export{y as T}; diff --git a/internal/api/dashboardspa/dist/assets/agentReads-DcDPDlRM.js b/internal/api/dashboardspa/dist/assets/agentReads-C0EYRgYm.js similarity index 80% rename from internal/api/dashboardspa/dist/assets/agentReads-DcDPDlRM.js rename to internal/api/dashboardspa/dist/assets/agentReads-C0EYRgYm.js index 38faa54dfd..c861c03009 100644 --- a/internal/api/dashboardspa/dist/assets/agentReads-DcDPDlRM.js +++ b/internal/api/dashboardspa/dist/assets/agentReads-C0EYRgYm.js @@ -1 +1 @@ -import{I as e,J as i}from"./index-BFDP6Xwd.js";async function n(){const r=await e().listAgents(i("list supervisor agents"));return{...r,items:r.items??[]}}async function a(r){const t=r.trim();if(t.length===0)throw new Error("agent alias is required");return e().agentPrime(i("fetch supervisor agent prime"),t)}export{a as f,n as l}; +import{I as e,J as i}from"./index-C20tCZFz.js";async function n(){const r=await e().listAgents(i("list supervisor agents"));return{...r,items:r.items??[]}}async function a(r){const t=r.trim();if(t.length===0)throw new Error("agent alias is required");return e().agentPrime(i("fetch supervisor agent prime"),t)}export{a as f,n as l}; diff --git a/internal/api/dashboardspa/dist/assets/constants-DOaI3lZl.js b/internal/api/dashboardspa/dist/assets/constants-DBKWGg29.js similarity index 95% rename from internal/api/dashboardspa/dist/assets/constants-DOaI3lZl.js rename to internal/api/dashboardspa/dist/assets/constants-DBKWGg29.js index 23b7338bf0..528dbd58d3 100644 --- a/internal/api/dashboardspa/dist/assets/constants-DOaI3lZl.js +++ b/internal/api/dashboardspa/dist/assets/constants-DBKWGg29.js @@ -1 +1 @@ -import{r as o,j as e}from"./index-BFDP6Xwd.js";function m({open:s,onClose:t,title:i,caption:a,children:l,footer:n,widthClass:d="max-w-3xl"}){return o.useEffect(()=>{if(!s)return;const r=c=>{c.key==="Escape"&&t()};return document.addEventListener("keydown",r),()=>document.removeEventListener("keydown",r)},[s,t]),s?e.jsx("div",{role:"dialog","aria-modal":"true",className:"fixed inset-0 z-50 flex items-start sm:items-center justify-center bg-fg/30 p-3 sm:p-6",onClick:t,children:e.jsxs("div",{className:`w-full ${d} bg-surface border border-rule rounded-md flex flex-col max-h-[90vh]`,onClick:r=>r.stopPropagation(),children:[e.jsxs("div",{className:"flex items-start justify-between gap-3 px-5 py-4 border-b border-rule",children:[e.jsxs("div",{className:"min-w-0",children:[e.jsx("h2",{className:"text-title font-semibold text-fg truncate",children:i}),a&&e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-muted mt-1 truncate",children:a})]}),e.jsx("button",{type:"button",onClick:t,"aria-label":"Close",className:"text-fg-muted hover:text-fg transition-colors duration-150 ease-out-quart focus-mark text-lg leading-none px-1",children:"×"})]}),e.jsx("div",{className:"flex-1 overflow-auto p-5 text-body text-fg",children:l}),n&&e.jsx("div",{className:"border-t border-rule px-5 py-3 flex items-center justify-end gap-3",children:n})]})}):null}const u="Content is agent-generated and may contain misleading instructions.";export{m as M,u as P}; +import{r as o,j as e}from"./index-C20tCZFz.js";function m({open:s,onClose:t,title:i,caption:a,children:l,footer:n,widthClass:d="max-w-3xl"}){return o.useEffect(()=>{if(!s)return;const r=c=>{c.key==="Escape"&&t()};return document.addEventListener("keydown",r),()=>document.removeEventListener("keydown",r)},[s,t]),s?e.jsx("div",{role:"dialog","aria-modal":"true",className:"fixed inset-0 z-50 flex items-start sm:items-center justify-center bg-fg/30 p-3 sm:p-6",onClick:t,children:e.jsxs("div",{className:`w-full ${d} bg-surface border border-rule rounded-md flex flex-col max-h-[90vh]`,onClick:r=>r.stopPropagation(),children:[e.jsxs("div",{className:"flex items-start justify-between gap-3 px-5 py-4 border-b border-rule",children:[e.jsxs("div",{className:"min-w-0",children:[e.jsx("h2",{className:"text-title font-semibold text-fg truncate",children:i}),a&&e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-muted mt-1 truncate",children:a})]}),e.jsx("button",{type:"button",onClick:t,"aria-label":"Close",className:"text-fg-muted hover:text-fg transition-colors duration-150 ease-out-quart focus-mark text-lg leading-none px-1",children:"×"})]}),e.jsx("div",{className:"flex-1 overflow-auto p-5 text-body text-fg",children:l}),n&&e.jsx("div",{className:"border-t border-rule px-5 py-3 flex items-center justify-end gap-3",children:n})]})}):null}const u="Content is agent-generated and may contain misleading instructions.";export{m as M,u as P}; diff --git a/internal/api/dashboardspa/dist/assets/index-BFDP6Xwd.js b/internal/api/dashboardspa/dist/assets/index-C20tCZFz.js similarity index 99% rename from internal/api/dashboardspa/dist/assets/index-BFDP6Xwd.js rename to internal/api/dashboardspa/dist/assets/index-C20tCZFz.js index 34abe50fea..6e8ff223ef 100644 --- a/internal/api/dashboardspa/dist/assets/index-BFDP6Xwd.js +++ b/internal/api/dashboardspa/dist/assets/index-C20tCZFz.js @@ -1,4 +1,4 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/Activity-C0ndMSgp.js","assets/routeHighlight-B30gQO2o.js","assets/PageHeader-5RHLpIfH.js","assets/time-D9v0saHV.js","assets/useVisibleRefresh-Bxd6CPUo.js","assets/Health-DWOkvU0J.js","assets/format-fte2CeYD.js","assets/Agents-sZ3Kn-9C.js","assets/context-window-Cu9zl36t.js","assets/projectOf-CJPpTC86.js","assets/constants-DOaI3lZl.js","assets/SseIndicator-DGo-aCtn.js","assets/LiveSessionPeek-Cjv3DcC3.js","assets/Table-3q0HSJQI.js","assets/agentReads-DcDPDlRM.js","assets/AgentDetail-4AW6d3TF.js","assets/BeadDetailModal-BKOlUSQL.js","assets/Field-BpdGqWpv.js","assets/AmbientHome-QKhI8-ES.js","assets/Beads-CRhPo2Gt.js","assets/useListFilters-CE9qAvrH.js","assets/Mail-CfeMOQZF.js","assets/FormulaRunDetail-BIoITriX.js","assets/StageLadder-BIFUAoAh.js","assets/Runs-DlWanzbB.js"])))=>i.map(i=>d[i]); +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/Activity-DTboxwTI.js","assets/routeHighlight-B30gQO2o.js","assets/PageHeader-D_D-jYn1.js","assets/time-D9v0saHV.js","assets/useVisibleRefresh-D_HCcAAw.js","assets/Health-C5mLLJQ2.js","assets/format-fte2CeYD.js","assets/Agents-CF9gHKR0.js","assets/context-window-Cu9zl36t.js","assets/projectOf-CwPPScnJ.js","assets/constants-DBKWGg29.js","assets/SseIndicator-CeTTAF2S.js","assets/LiveSessionPeek-jm19JJ4Z.js","assets/Table-DojZJIvD.js","assets/agentReads-C0EYRgYm.js","assets/AgentDetail-DVT9Be-a.js","assets/BeadDetailModal-BtVrX_Fu.js","assets/Field-Dsl4x4KL.js","assets/AmbientHome-usE4zKNv.js","assets/Beads-DJjixOgD.js","assets/useListFilters-C0Eq1DLc.js","assets/Mail-767k9Nkh.js","assets/FormulaRunDetail-CFys0Xia.js","assets/StageLadder-B3yZa5o4.js","assets/Runs-BCTFHOlQ.js"])))=>i.map(i=>d[i]); function Pg(t,r){for(var i=0;is[u]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const u of document.querySelectorAll('link[rel="modulepreload"]'))s(u);new MutationObserver(u=>{for(const p of u)if(p.type==="childList")for(const d of p.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&s(d)}).observe(document,{childList:!0,subtree:!0});function i(u){const p={};return u.integrity&&(p.integrity=u.integrity),u.referrerPolicy&&(p.referrerPolicy=u.referrerPolicy),u.crossOrigin==="use-credentials"?p.credentials="include":u.crossOrigin==="anonymous"?p.credentials="omit":p.credentials="same-origin",p}function s(u){if(u.ep)return;u.ep=!0;const p=i(u);fetch(u.href,p)}})();function qf(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var Al={exports:{}},Vo={},Ol={exports:{}},he={};var Mp;function Ng(){if(Mp)return he;Mp=1;var t=Symbol.for("react.element"),r=Symbol.for("react.portal"),i=Symbol.for("react.fragment"),s=Symbol.for("react.strict_mode"),u=Symbol.for("react.profiler"),p=Symbol.for("react.provider"),d=Symbol.for("react.context"),m=Symbol.for("react.forward_ref"),g=Symbol.for("react.suspense"),y=Symbol.for("react.memo"),E=Symbol.for("react.lazy"),S=Symbol.iterator;function T(z){return z===null||typeof z!="object"?null:(z=S&&z[S]||z["@@iterator"],typeof z=="function"?z:null)}var A={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},D=Object.assign,W={};function O(z,F,me){this.props=z,this.context=F,this.refs=W,this.updater=me||A}O.prototype.isReactComponent={},O.prototype.setState=function(z,F){if(typeof z!="object"&&typeof z!="function"&&z!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,z,F,"setState")},O.prototype.forceUpdate=function(z){this.updater.enqueueForceUpdate(this,z,"forceUpdate")};function H(){}H.prototype=O.prototype;function oe(z,F,me){this.props=z,this.context=F,this.refs=W,this.updater=me||A}var Q=oe.prototype=new H;Q.constructor=oe,D(Q,O.prototype),Q.isPureReactComponent=!0;var G=Array.isArray,ee=Object.prototype.hasOwnProperty,ue={current:null},de={key:!0,ref:!0,__self:!0,__source:!0};function pe(z,F,me){var ge,we={},xe=null,Ce=null;if(F!=null)for(ge in F.ref!==void 0&&(Ce=F.ref),F.key!==void 0&&(xe=""+F.key),F)ee.call(F,ge)&&!de.hasOwnProperty(ge)&&(we[ge]=F[ge]);var ke=arguments.length-2;if(ke===1)we.children=me;else if(1>>1,F=J[z];if(0>>1;zu(we,X))xeu(Ce,we)?(J[z]=Ce,J[xe]=X,z=xe):(J[z]=we,J[ge]=X,z=ge);else if(xeu(Ce,X))J[z]=Ce,J[xe]=X,z=xe;else break e}}return le}function u(J,le){var X=J.sortIndex-le.sortIndex;return X!==0?X:J.id-le.id}if(typeof performance=="object"&&typeof performance.now=="function"){var p=performance;t.unstable_now=function(){return p.now()}}else{var d=Date,m=d.now();t.unstable_now=function(){return d.now()-m}}var g=[],y=[],E=1,S=null,T=3,A=!1,D=!1,W=!1,O=typeof setTimeout=="function"?setTimeout:null,H=typeof clearTimeout=="function"?clearTimeout:null,oe=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function Q(J){for(var le=i(y);le!==null;){if(le.callback===null)s(y);else if(le.startTime<=J)s(y),le.sortIndex=le.expirationTime,r(g,le);else break;le=i(y)}}function G(J){if(W=!1,Q(J),!D)if(i(g)!==null)D=!0,vt(ee);else{var le=i(y);le!==null&&qe(G,le.startTime-J)}}function ee(J,le){D=!1,W&&(W=!1,H(pe),pe=-1),A=!0;var X=T;try{for(Q(le),S=i(g);S!==null&&(!(S.expirationTime>le)||J&&!Ze());){var z=S.callback;if(typeof z=="function"){S.callback=null,T=S.priorityLevel;var F=z(S.expirationTime<=le);le=t.unstable_now(),typeof F=="function"?S.callback=F:S===i(g)&&s(g),Q(le)}else s(g);S=i(g)}if(S!==null)var me=!0;else{var ge=i(y);ge!==null&&qe(G,ge.startTime-le),me=!1}return me}finally{S=null,T=X,A=!1}}var ue=!1,de=null,pe=-1,Re=5,ye=-1;function Ze(){return!(t.unstable_now()-yeJ||125z?(J.sortIndex=X,r(y,J),i(g)===null&&J===i(y)&&(W?(H(pe),pe=-1):W=!0,qe(G,X-z))):(J.sortIndex=F,r(g,J),D||A||(D=!0,vt(ee))),J},t.unstable_shouldYield=Ze,t.unstable_wrapCallback=function(J){var le=T;return function(){var X=T;T=le;try{return J.apply(this,arguments)}finally{T=X}}}})(Ll)),Ll}var Vp;function Lg(){return Vp||(Vp=1,$l.exports=$g()),$l.exports}var Wp;function Dg(){if(Wp)return xt;Wp=1;var t=iu(),r=Lg();function i(e){for(var n="https://reactjs.org/docs/error-decoder.html?invariant="+e,a=1;a"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),g=Object.prototype.hasOwnProperty,y=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,E={},S={};function T(e){return g.call(S,e)?!0:g.call(E,e)?!1:y.test(e)?S[e]=!0:(E[e]=!0,!1)}function A(e,n,a,l){if(a!==null&&a.type===0)return!1;switch(typeof n){case"function":case"symbol":return!0;case"boolean":return l?!1:a!==null?!a.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function D(e,n,a,l){if(n===null||typeof n>"u"||A(e,n,a,l))return!0;if(l)return!1;if(a!==null)switch(a.type){case 3:return!n;case 4:return n===!1;case 5:return isNaN(n);case 6:return isNaN(n)||1>n}return!1}function W(e,n,a,l,c,f,v){this.acceptsBooleans=n===2||n===3||n===4,this.attributeName=l,this.attributeNamespace=c,this.mustUseProperty=a,this.propertyName=e,this.type=n,this.sanitizeURL=f,this.removeEmptyString=v}var O={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){O[e]=new W(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var n=e[0];O[n]=new W(n,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){O[e]=new W(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){O[e]=new W(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){O[e]=new W(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){O[e]=new W(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){O[e]=new W(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){O[e]=new W(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){O[e]=new W(e,5,!1,e.toLowerCase(),null,!1,!1)});var H=/[\-:]([a-z])/g;function oe(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var n=e.replace(H,oe);O[n]=new W(n,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var n=e.replace(H,oe);O[n]=new W(n,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var n=e.replace(H,oe);O[n]=new W(n,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){O[e]=new W(e,1,!1,e.toLowerCase(),null,!1,!1)}),O.xlinkHref=new W("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){O[e]=new W(e,1,!1,e.toLowerCase(),null,!0,!0)});function Q(e,n,a,l){var c=O.hasOwnProperty(n)?O[n]:null;(c!==null?c.type!==0:l||!(2oe(T,Q,G)};let p;const d=Qo,m=!du.jitless,y=m&&L7.value,E=r.catchall;let S;t._zod.parse=(T,A)=>{S??(S=s.value);const D=T.value;return d(D)?m&&y&&A?.async===!1&&A.jitless!==!0?(p||(p=u(r.shape)),T=p(T,A),E?Nm([],D,T,A,S,t):T):i(T,A):(T.issues.push({expected:"object",code:"invalid_type",input:D,inst:t}),T)}});function gf(t,r,i,s){for(const p of t)if(p.issues.length===0)return r.value=p.value,r;const u=t.filter(p=>!qr(p));return u.length===1?(r.value=u[0].value,u[0]):(r.issues.push({code:"invalid_union",input:r.value,inst:i,errors:t.map(p=>p.issues.map(d=>hn(d,s,vn())))}),r)}const Am=j("$ZodUnion",(t,r)=>{je.init(t,r),ze(t._zod,"optin",()=>r.options.some(s=>s._zod.optin==="optional")?"optional":void 0),ze(t._zod,"optout",()=>r.options.some(s=>s._zod.optout==="optional")?"optional":void 0),ze(t._zod,"values",()=>{if(r.options.every(s=>s._zod.values))return new Set(r.options.flatMap(s=>Array.from(s._zod.values)))}),ze(t._zod,"pattern",()=>{if(r.options.every(s=>s._zod.pattern)){const s=r.options.map(u=>u._zod.pattern);return new RegExp(`^(${s.map(u=>fu(u.source)).join("|")})$`)}});const i=r.options.length===1?r.options[0]._zod.run:null;t._zod.parse=(s,u)=>{if(i)return i(s,u);let p=!1;const d=[];for(const m of r.options){const g=m._zod.run({value:s.value,issues:[]},u);if(g instanceof Promise)d.push(g),p=!0;else{if(g.issues.length===0)return g;d.push(g)}}return p?Promise.all(d).then(m=>gf(m,s,t,u)):gf(d,s,t,u)}}),C3=j("$ZodDiscriminatedUnion",(t,r)=>{r.inclusive=!1,Am.init(t,r);const i=t._zod.parse;ze(t._zod,"propValues",()=>{const u={};for(const p of r.options){const d=p._zod.propValues;if(!d||Object.keys(d).length===0)throw new Error(`Invalid discriminated union option at index "${r.options.indexOf(p)}"`);for(const[m,g]of Object.entries(d)){u[m]||(u[m]=new Set);for(const y of g)u[m].add(y)}}return u});const s=Na(()=>{const u=r.options,p=new Map;for(const d of u){const m=d._zod.propValues?.[r.discriminator];if(!m||m.size===0)throw new Error(`Invalid discriminated union option at index "${r.options.indexOf(d)}"`);for(const g of m){if(p.has(g))throw new Error(`Duplicate discriminator value "${String(g)}"`);p.set(g,d)}}return p});t._zod.parse=(u,p)=>{const d=u.value;if(!Qo(d))return u.issues.push({code:"invalid_type",expected:"object",input:d,inst:t}),u;const m=s.value.get(d?.[r.discriminator]);return m?m._zod.run(u,p):r.unionFallback||p.direction==="backward"?i(u,p):(u.issues.push({code:"invalid_union",errors:[],note:"No matching discriminator",discriminator:r.discriminator,options:Array.from(s.value.keys()),input:d,path:[r.discriminator],inst:t}),u)}}),T3=j("$ZodIntersection",(t,r)=>{je.init(t,r),t._zod.parse=(i,s)=>{const u=i.value,p=r.left._zod.run({value:u,issues:[]},s),d=r.right._zod.run({value:u,issues:[]},s);return p instanceof Promise||d instanceof Promise?Promise.all([p,d]).then(([g,y])=>yf(i,g,y)):yf(i,p,d)}});function Ql(t,r){if(t===r)return{valid:!0,data:t};if(t instanceof Date&&r instanceof Date&&+t==+r)return{valid:!0,data:t};if(Xr(t)&&Xr(r)){const i=Object.keys(r),s=Object.keys(t).filter(p=>i.indexOf(p)!==-1),u={...t,...r};for(const p of s){const d=Ql(t[p],r[p]);if(!d.valid)return{valid:!1,mergeErrorPath:[p,...d.mergeErrorPath]};u[p]=d.data}return{valid:!0,data:u}}if(Array.isArray(t)&&Array.isArray(r)){if(t.length!==r.length)return{valid:!1,mergeErrorPath:[]};const i=[];for(let s=0;sm.l&&m.r).map(([m])=>m);if(p.length&&u&&t.issues.push({...u,keys:p}),qr(t))return t;const d=Ql(r.value,i.value);if(!d.valid)throw new Error(`Unmergable intersection. Error path: ${JSON.stringify(d.mergeErrorPath)}`);return t.value=d.data,t}const B3=j("$ZodRecord",(t,r)=>{je.init(t,r),t._zod.parse=(i,s)=>{const u=i.value;if(!Xr(u))return i.issues.push({expected:"record",code:"invalid_type",input:u,inst:t}),i;const p=[],d=r.keyType._zod.values;if(d){i.value={};const m=new Set;for(const y of d)if(typeof y=="string"||typeof y=="number"||typeof y=="symbol"){m.add(typeof y=="number"?y.toString():y);const E=r.keyType._zod.run({value:y,issues:[]},s);if(E instanceof Promise)throw new Error("Async schemas not supported in object keys currently");if(E.issues.length){i.issues.push({code:"invalid_key",origin:"record",issues:E.issues.map(A=>hn(A,s,vn())),input:y,path:[y],inst:t});continue}const S=E.value,T=r.valueType._zod.run({value:u[y],issues:[]},s);T instanceof Promise?p.push(T.then(A=>{A.issues.length&&i.issues.push(...Vr(y,A.issues)),i.value[S]=A.value})):(T.issues.length&&i.issues.push(...Vr(y,T.issues)),i.value[S]=T.value)}let g;for(const y in u)m.has(y)||(g=g??[],g.push(y));g&&g.length>0&&i.issues.push({code:"unrecognized_keys",input:u,inst:t,keys:g})}else{i.value={};for(const m of Reflect.ownKeys(u)){if(m==="__proto__"||!Object.prototype.propertyIsEnumerable.call(u,m))continue;let g=r.keyType._zod.run({value:m,issues:[]},s);if(g instanceof Promise)throw new Error("Async schemas not supported in object keys currently");if(typeof m=="string"&&bm.test(m)&&g.issues.length){const S=r.keyType._zod.run({value:Number(m),issues:[]},s);if(S instanceof Promise)throw new Error("Async schemas not supported in object keys currently");S.issues.length===0&&(g=S)}if(g.issues.length){r.mode==="loose"?i.value[m]=u[m]:i.issues.push({code:"invalid_key",origin:"record",issues:g.issues.map(S=>hn(S,s,vn())),input:m,path:[m],inst:t});continue}const E=r.valueType._zod.run({value:u[m],issues:[]},s);E instanceof Promise?p.push(E.then(S=>{S.issues.length&&i.issues.push(...Vr(m,S.issues)),i.value[g.value]=S.value})):(E.issues.length&&i.issues.push(...Vr(m,E.issues)),i.value[g.value]=E.value)}}return p.length?Promise.all(p).then(()=>i):i}}),R3=j("$ZodEnum",(t,r)=>{je.init(t,r);const i=gm(r.entries),s=new Set(i);t._zod.values=s,t._zod.pattern=new RegExp(`^(${i.filter(u=>D7.has(typeof u)).map(u=>typeof u=="string"?eo(u):u.toString()).join("|")})$`),t._zod.parse=(u,p)=>{const d=u.value;return s.has(d)||u.issues.push({code:"invalid_value",values:i,input:d,inst:t}),u}}),P3=j("$ZodLiteral",(t,r)=>{if(je.init(t,r),r.values.length===0)throw new Error("Cannot create literal schema with no valid values");const i=new Set(r.values);t._zod.values=i,t._zod.pattern=new RegExp(`^(${r.values.map(s=>typeof s=="string"?eo(s):s?eo(s.toString()):String(s)).join("|")})$`),t._zod.parse=(s,u)=>{const p=s.value;return i.has(p)||s.issues.push({code:"invalid_value",values:r.values,input:p,inst:t}),s}}),N3=j("$ZodTransform",(t,r)=>{je.init(t,r),t._zod.optin="optional",t._zod.parse=(i,s)=>{if(s.direction==="backward")throw new hm(t.constructor.name);const u=r.transform(i.value,i);if(s.async)return(u instanceof Promise?u:Promise.resolve(u)).then(d=>(i.value=d,i.fallback=!0,i));if(u instanceof Promise)throw new Hr;return i.value=u,i.fallback=!0,i}});function _f(t,r){return r===void 0&&(t.issues.length||t.fallback)?{issues:[],value:void 0}:t}const Om=j("$ZodOptional",(t,r)=>{je.init(t,r),t._zod.optin="optional",t._zod.optout="optional",ze(t._zod,"values",()=>r.innerType._zod.values?new Set([...r.innerType._zod.values,void 0]):void 0),ze(t._zod,"pattern",()=>{const i=r.innerType._zod.pattern;return i?new RegExp(`^(${fu(i.source)})?$`):void 0}),t._zod.parse=(i,s)=>{if(r.innerType._zod.optin==="optional"){const u=i.value,p=r.innerType._zod.run(i,s);return p instanceof Promise?p.then(d=>_f(d,u)):_f(p,u)}return i.value===void 0?i:r.innerType._zod.run(i,s)}}),A3=j("$ZodExactOptional",(t,r)=>{Om.init(t,r),ze(t._zod,"values",()=>r.innerType._zod.values),ze(t._zod,"pattern",()=>r.innerType._zod.pattern),t._zod.parse=(i,s)=>r.innerType._zod.run(i,s)}),O3=j("$ZodNullable",(t,r)=>{je.init(t,r),ze(t._zod,"optin",()=>r.innerType._zod.optin),ze(t._zod,"optout",()=>r.innerType._zod.optout),ze(t._zod,"pattern",()=>{const i=r.innerType._zod.pattern;return i?new RegExp(`^(${fu(i.source)}|null)$`):void 0}),ze(t._zod,"values",()=>r.innerType._zod.values?new Set([...r.innerType._zod.values,null]):void 0),t._zod.parse=(i,s)=>i.value===null?i:r.innerType._zod.run(i,s)}),j3=j("$ZodDefault",(t,r)=>{je.init(t,r),t._zod.optin="optional",ze(t._zod,"values",()=>r.innerType._zod.values),t._zod.parse=(i,s)=>{if(s.direction==="backward")return r.innerType._zod.run(i,s);if(i.value===void 0)return i.value=r.defaultValue,i;const u=r.innerType._zod.run(i,s);return u instanceof Promise?u.then(p=>wf(p,r)):wf(u,r)}});function wf(t,r){return t.value===void 0&&(t.value=r.defaultValue),t}const $3=j("$ZodPrefault",(t,r)=>{je.init(t,r),t._zod.optin="optional",ze(t._zod,"values",()=>r.innerType._zod.values),t._zod.parse=(i,s)=>(s.direction==="backward"||i.value===void 0&&(i.value=r.defaultValue),r.innerType._zod.run(i,s))}),L3=j("$ZodNonOptional",(t,r)=>{je.init(t,r),ze(t._zod,"values",()=>{const i=r.innerType._zod.values;return i?new Set([...i].filter(s=>s!==void 0)):void 0}),t._zod.parse=(i,s)=>{const u=r.innerType._zod.run(i,s);return u instanceof Promise?u.then(p=>xf(p,t)):xf(u,t)}});function xf(t,r){return!t.issues.length&&t.value===void 0&&t.issues.push({code:"invalid_type",expected:"nonoptional",input:t.value,inst:r}),t}const D3=j("$ZodCatch",(t,r)=>{je.init(t,r),t._zod.optin="optional",ze(t._zod,"optout",()=>r.innerType._zod.optout),ze(t._zod,"values",()=>r.innerType._zod.values),t._zod.parse=(i,s)=>{if(s.direction==="backward")return r.innerType._zod.run(i,s);const u=r.innerType._zod.run(i,s);return u instanceof Promise?u.then(p=>(i.value=p.value,p.issues.length&&(i.value=r.catchValue({...i,error:{issues:p.issues.map(d=>hn(d,s,vn()))},input:i.value}),i.issues=[],i.fallback=!0),i)):(i.value=u.value,u.issues.length&&(i.value=r.catchValue({...i,error:{issues:u.issues.map(p=>hn(p,s,vn()))},input:i.value}),i.issues=[],i.fallback=!0),i)}}),M3=j("$ZodPipe",(t,r)=>{je.init(t,r),ze(t._zod,"values",()=>r.in._zod.values),ze(t._zod,"optin",()=>r.in._zod.optin),ze(t._zod,"optout",()=>r.out._zod.optout),ze(t._zod,"propValues",()=>r.in._zod.propValues),t._zod.parse=(i,s)=>{if(s.direction==="backward"){const p=r.out._zod.run(i,s);return p instanceof Promise?p.then(d=>ga(d,r.in,s)):ga(p,r.in,s)}const u=r.in._zod.run(i,s);return u instanceof Promise?u.then(p=>ga(p,r.out,s)):ga(u,r.out,s)}});function ga(t,r,i){return t.issues.length?(t.aborted=!0,t):r._zod.run({value:t.value,issues:t.issues,fallback:t.fallback},i)}const F3=j("$ZodReadonly",(t,r)=>{je.init(t,r),ze(t._zod,"propValues",()=>r.innerType._zod.propValues),ze(t._zod,"values",()=>r.innerType._zod.values),ze(t._zod,"optin",()=>r.innerType?._zod?.optin),ze(t._zod,"optout",()=>r.innerType?._zod?.optout),t._zod.parse=(i,s)=>{if(s.direction==="backward")return r.innerType._zod.run(i,s);const u=r.innerType._zod.run(i,s);return u instanceof Promise?u.then(Ef):Ef(u)}});function Ef(t){return t.value=Object.freeze(t.value),t}const U3=j("$ZodCustom",(t,r)=>{St.init(t,r),je.init(t,r),t._zod.parse=(i,s)=>i,t._zod.check=i=>{const s=i.value,u=r.fn(s);if(u instanceof Promise)return u.then(p=>If(p,i,s,t));If(u,i,s,t)}});function If(t,r,i,s){if(!t){const u={code:"custom",input:i,inst:s,path:[...s._zod.def.path??[]],continue:!s._zod.def.abort};s._zod.def.params&&(u.params=s._zod.def.params),r.issues.push(Yo(u))}}var Sf;class Z3{constructor(){this._map=new WeakMap,this._idmap=new Map}add(r,...i){const s=i[0];return this._map.set(r,s),s&&typeof s=="object"&&"id"in s&&this._idmap.set(s.id,r),this}clear(){return this._map=new WeakMap,this._idmap=new Map,this}remove(r){const i=this._map.get(r);return i&&typeof i=="object"&&"id"in i&&this._idmap.delete(i.id),this._map.delete(r),this}get(r){const i=r._zod.parent;if(i){const s={...this.get(i)??{}};delete s.id;const u={...s,...this._map.get(r)};return Object.keys(u).length?u:void 0}return this._map.get(r)}has(r){return this._map.has(r)}}function q3(){return new Z3}(Sf=globalThis).__zod_globalRegistry??(Sf.__zod_globalRegistry=q3());const Wo=globalThis.__zod_globalRegistry;function V3(t,r){return new t({type:"string",...ie(r)})}function W3(t,r){return new t({type:"string",format:"email",check:"string_format",abort:!1,...ie(r)})}function kf(t,r){return new t({type:"string",format:"guid",check:"string_format",abort:!1,...ie(r)})}function H3(t,r){return new t({type:"string",format:"uuid",check:"string_format",abort:!1,...ie(r)})}function G3(t,r){return new t({type:"string",format:"uuid",check:"string_format",abort:!1,version:"v4",...ie(r)})}function J3(t,r){return new t({type:"string",format:"uuid",check:"string_format",abort:!1,version:"v6",...ie(r)})}function K3(t,r){return new t({type:"string",format:"uuid",check:"string_format",abort:!1,version:"v7",...ie(r)})}function jm(t,r){return new t({type:"string",format:"url",check:"string_format",abort:!1,...ie(r)})}function Q3(t,r){return new t({type:"string",format:"emoji",check:"string_format",abort:!1,...ie(r)})}function Y3(t,r){return new t({type:"string",format:"nanoid",check:"string_format",abort:!1,...ie(r)})}function X3(t,r){return new t({type:"string",format:"cuid",check:"string_format",abort:!1,...ie(r)})}function e_(t,r){return new t({type:"string",format:"cuid2",check:"string_format",abort:!1,...ie(r)})}function t_(t,r){return new t({type:"string",format:"ulid",check:"string_format",abort:!1,...ie(r)})}function n_(t,r){return new t({type:"string",format:"xid",check:"string_format",abort:!1,...ie(r)})}function r_(t,r){return new t({type:"string",format:"ksuid",check:"string_format",abort:!1,...ie(r)})}function o_(t,r){return new t({type:"string",format:"ipv4",check:"string_format",abort:!1,...ie(r)})}function i_(t,r){return new t({type:"string",format:"ipv6",check:"string_format",abort:!1,...ie(r)})}function a_(t,r){return new t({type:"string",format:"cidrv4",check:"string_format",abort:!1,...ie(r)})}function s_(t,r){return new t({type:"string",format:"cidrv6",check:"string_format",abort:!1,...ie(r)})}function l_(t,r){return new t({type:"string",format:"base64",check:"string_format",abort:!1,...ie(r)})}function u_(t,r){return new t({type:"string",format:"base64url",check:"string_format",abort:!1,...ie(r)})}function c_(t,r){return new t({type:"string",format:"e164",check:"string_format",abort:!1,...ie(r)})}function d_(t,r){return new t({type:"string",format:"jwt",check:"string_format",abort:!1,...ie(r)})}function p_(t,r){return new t({type:"string",format:"datetime",check:"string_format",offset:!1,local:!1,precision:null,...ie(r)})}function f_(t,r){return new t({type:"string",format:"date",check:"string_format",...ie(r)})}function m_(t,r){return new t({type:"string",format:"time",check:"string_format",precision:null,...ie(r)})}function v_(t,r){return new t({type:"string",format:"duration",check:"string_format",...ie(r)})}function h_(t,r){return new t({type:"number",checks:[],...ie(r)})}function g_(t,r){return new t({type:"number",check:"number_format",abort:!1,format:"safeint",...ie(r)})}function y_(t,r){return new t({type:"boolean",...ie(r)})}function __(t,r){return new t({type:"bigint",coerce:!0,...ie(r)})}function w_(t){return new t({type:"unknown"})}function x_(t,r){return new t({type:"never",...ie(r)})}function Ia(t,r){return new Cm({check:"less_than",...ie(r),value:t,inclusive:!1})}function Gr(t,r){return new Cm({check:"less_than",...ie(r),value:t,inclusive:!0})}function Sa(t,r){return new Tm({check:"greater_than",...ie(r),value:t,inclusive:!1})}function Un(t,r){return new Tm({check:"greater_than",...ie(r),value:t,inclusive:!0})}function Yl(t,r){return new O2({check:"multiple_of",...ie(r),value:t})}function $m(t,r){return new $2({check:"max_length",...ie(r),maximum:t})}function ka(t,r){return new L2({check:"min_length",...ie(r),minimum:t})}function Lm(t,r){return new D2({check:"length_equals",...ie(r),length:t})}function E_(t,r){return new M2({check:"string_format",format:"regex",...ie(r),pattern:t})}function I_(t){return new F2({check:"string_format",format:"lowercase",...ie(t)})}function S_(t){return new U2({check:"string_format",format:"uppercase",...ie(t)})}function k_(t,r){return new Z2({check:"string_format",format:"includes",...ie(r),includes:t})}function b_(t,r){return new q2({check:"string_format",format:"starts_with",...ie(r),prefix:t})}function z_(t,r){return new V2({check:"string_format",format:"ends_with",...ie(r),suffix:t})}function ro(t){return new W2({check:"overwrite",tx:t})}function C_(t){return ro(r=>r.normalize(t))}function T_(){return ro(t=>t.trim())}function B_(){return ro(t=>t.toLowerCase())}function R_(){return ro(t=>t.toUpperCase())}function P_(){return ro(t=>$7(t))}function N_(t,r,i){return new t({type:"array",element:r,...ie(i)})}function A_(t,r,i){return new t({type:"custom",check:"custom",fn:r,...ie(i)})}function O_(t,r){const i=j_(s=>(s.addIssue=u=>{if(typeof u=="string")s.issues.push(Yo(u,s.value,i._zod.def));else{const p=u;p.fatal&&(p.continue=!1),p.code??(p.code="custom"),p.input??(p.input=s.value),p.inst??(p.inst=i),p.continue??(p.continue=!i._zod.def.abort),s.issues.push(Yo(p))}},t(s.value,s)),r);return i}function j_(t,r){const i=new St({check:"custom",...ie(r)});return i._zod.check=t,i}function Dm(t){let r=t?.target??"draft-2020-12";return r==="draft-4"&&(r="draft-04"),r==="draft-7"&&(r="draft-07"),{processors:t.processors??{},metadataRegistry:t?.metadata??Wo,target:r,unrepresentable:t?.unrepresentable??"throw",override:t?.override??(()=>{}),io:t?.io??"output",counter:0,seen:new Map,cycles:t?.cycles??"ref",reused:t?.reused??"inline",external:t?.external??void 0}}function Je(t,r,i={path:[],schemaPath:[]}){var s;const u=t._zod.def,p=r.seen.get(t);if(p)return p.count++,i.schemaPath.includes(t)&&(p.cycle=i.path),p.schema;const d={schema:{},count:1,cycle:void 0,path:i.path};r.seen.set(t,d);const m=t._zod.toJSONSchema?.();if(m)d.schema=m;else{const E={...i,schemaPath:[...i.schemaPath,t],path:i.path};if(t._zod.processJSONSchema)t._zod.processJSONSchema(r,d.schema,E);else{const T=d.schema,A=r.processors[u.type];if(!A)throw new Error(`[toJSONSchema]: Non-representable type encountered: ${u.type}`);A(t,r,T,E)}const S=t._zod.parent;S&&(d.ref||(d.ref=S),Je(S,r,E),r.seen.get(S).isParent=!0)}const g=r.metadataRegistry.get(t);return g&&Object.assign(d.schema,g),r.io==="input"&&ft(t)&&(delete d.schema.examples,delete d.schema.default),r.io==="input"&&"_prefault"in d.schema&&((s=d.schema).default??(s.default=d.schema._prefault)),delete d.schema._prefault,r.seen.get(t).schema}function Mm(t,r){const i=t.seen.get(r);if(!i)throw new Error("Unprocessed schema. This is a bug in Zod.");const s=new Map;for(const d of t.seen.entries()){const m=t.metadataRegistry.get(d[0])?.id;if(m){const g=s.get(m);if(g&&g!==d[0])throw new Error(`Duplicate schema id "${m}" detected during JSON Schema conversion. Two different schemas cannot share the same id when converted together.`);s.set(m,d[0])}}const u=d=>{const m=t.target==="draft-2020-12"?"$defs":"definitions";if(t.external){const S=t.external.registry.get(d[0])?.id,T=t.external.uri??(D=>D);if(S)return{ref:T(S)};const A=d[1].defId??d[1].schema.id??`schema${t.counter++}`;return d[1].defId=A,{defId:A,ref:`${T("__shared")}#/${m}/${A}`}}if(d[1]===i)return{ref:"#"};const y=`#/${m}/`,E=d[1].schema.id??`__schema${t.counter++}`;return{defId:E,ref:y+E}},p=d=>{if(d[1].schema.$ref)return;const m=d[1],{ref:g,defId:y}=u(d);m.def={...m.schema},y&&(m.defId=y);const E=m.schema;for(const S in E)delete E[S];E.$ref=g};if(t.cycles==="throw")for(const d of t.seen.entries()){const m=d[1];if(m.cycle)throw new Error(`Cycle detected: #/${m.cycle?.join("/")}/ -Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.`)}for(const d of t.seen.entries()){const m=d[1];if(r===d[0]){p(d);continue}if(t.external){const y=t.external.registry.get(d[0])?.id;if(r!==d[0]&&y){p(d);continue}}if(t.metadataRegistry.get(d[0])?.id){p(d);continue}if(m.cycle){p(d);continue}if(m.count>1&&t.reused==="ref"){p(d);continue}}}function Fm(t,r){const i=t.seen.get(r);if(!i)throw new Error("Unprocessed schema. This is a bug in Zod.");const s=m=>{const g=t.seen.get(m);if(g.ref===null)return;const y=g.def??g.schema,E={...y},S=g.ref;if(g.ref=null,S){s(S);const A=t.seen.get(S),D=A.schema;if(D.$ref&&(t.target==="draft-07"||t.target==="draft-04"||t.target==="openapi-3.0")?(y.allOf=y.allOf??[],y.allOf.push(D)):Object.assign(y,D),Object.assign(y,E),m._zod.parent===S)for(const O in y)O==="$ref"||O==="allOf"||O in E||delete y[O];if(D.$ref&&A.def)for(const O in y)O==="$ref"||O==="allOf"||O in A.def&&JSON.stringify(y[O])===JSON.stringify(A.def[O])&&delete y[O]}const T=m._zod.parent;if(T&&T!==S){s(T);const A=t.seen.get(T);if(A?.schema.$ref&&(y.$ref=A.schema.$ref,A.def))for(const D in y)D==="$ref"||D==="allOf"||D in A.def&&JSON.stringify(y[D])===JSON.stringify(A.def[D])&&delete y[D]}t.override({zodSchema:m,jsonSchema:y,path:g.path??[]})};for(const m of[...t.seen.entries()].reverse())s(m[0]);const u={};if(t.target==="draft-2020-12"?u.$schema="https://json-schema.org/draft/2020-12/schema":t.target==="draft-07"?u.$schema="http://json-schema.org/draft-07/schema#":t.target==="draft-04"?u.$schema="http://json-schema.org/draft-04/schema#":t.target,t.external?.uri){const m=t.external.registry.get(r)?.id;if(!m)throw new Error("Schema is missing an `id` property");u.$id=t.external.uri(m)}Object.assign(u,i.def??i.schema);const p=t.metadataRegistry.get(r)?.id;p!==void 0&&u.id===p&&delete u.id;const d=t.external?.defs??{};for(const m of t.seen.entries()){const g=m[1];g.def&&g.defId&&(g.def.id===g.defId&&delete g.def.id,d[g.defId]=g.def)}t.external||Object.keys(d).length>0&&(t.target==="draft-2020-12"?u.$defs=d:u.definitions=d);try{const m=JSON.parse(JSON.stringify(u));return Object.defineProperty(m,"~standard",{value:{...r["~standard"],jsonSchema:{input:ba(r,"input",t.processors),output:ba(r,"output",t.processors)}},enumerable:!1,writable:!1}),m}catch{throw new Error("Error converting schema to JSON.")}}function ft(t,r){const i=r??{seen:new Set};if(i.seen.has(t))return!1;i.seen.add(t);const s=t._zod.def;if(s.type==="transform")return!0;if(s.type==="array")return ft(s.element,i);if(s.type==="set")return ft(s.valueType,i);if(s.type==="lazy")return ft(s.getter(),i);if(s.type==="promise"||s.type==="optional"||s.type==="nonoptional"||s.type==="nullable"||s.type==="readonly"||s.type==="default"||s.type==="prefault")return ft(s.innerType,i);if(s.type==="intersection")return ft(s.left,i)||ft(s.right,i);if(s.type==="record"||s.type==="map")return ft(s.keyType,i)||ft(s.valueType,i);if(s.type==="pipe")return t._zod.traits.has("$ZodCodec")?!0:ft(s.in,i)||ft(s.out,i);if(s.type==="object"){for(const u in s.shape)if(ft(s.shape[u],i))return!0;return!1}if(s.type==="union"){for(const u of s.options)if(ft(u,i))return!0;return!1}if(s.type==="tuple"){for(const u of s.items)if(ft(u,i))return!0;return!!(s.rest&&ft(s.rest,i))}return!1}const $_=(t,r={})=>i=>{const s=Dm({...i,processors:r});return Je(t,s),Mm(s,t),Fm(s,t)},ba=(t,r,i={})=>s=>{const{libraryOptions:u,target:p}=s??{},d=Dm({...u??{},target:p,io:r,processors:i});return Je(t,d),Mm(d,t),Fm(d,t)},L_={guid:"uuid",url:"uri",datetime:"date-time",json_string:"json-string",regex:""},D_=(t,r,i,s)=>{const u=i;u.type="string";const{minimum:p,maximum:d,format:m,patterns:g,contentEncoding:y}=t._zod.bag;if(typeof p=="number"&&(u.minLength=p),typeof d=="number"&&(u.maxLength=d),m&&(u.format=L_[m]??m,u.format===""&&delete u.format,m==="time"&&delete u.format),y&&(u.contentEncoding=y),g&&g.size>0){const E=[...g];E.length===1?u.pattern=E[0].source:E.length>1&&(u.allOf=[...E.map(S=>({...r.target==="draft-07"||r.target==="draft-04"||r.target==="openapi-3.0"?{type:"string"}:{},pattern:S.source}))])}},M_=(t,r,i,s)=>{const u=i,{minimum:p,maximum:d,format:m,multipleOf:g,exclusiveMaximum:y,exclusiveMinimum:E}=t._zod.bag;typeof m=="string"&&m.includes("int")?u.type="integer":u.type="number";const S=typeof E=="number"&&E>=(p??Number.NEGATIVE_INFINITY),T=typeof y=="number"&&y<=(d??Number.POSITIVE_INFINITY),A=r.target==="draft-04"||r.target==="openapi-3.0";S?A?(u.minimum=E,u.exclusiveMinimum=!0):u.exclusiveMinimum=E:typeof p=="number"&&(u.minimum=p),T?A?(u.maximum=y,u.exclusiveMaximum=!0):u.exclusiveMaximum=y:typeof d=="number"&&(u.maximum=d),typeof g=="number"&&(u.multipleOf=g)},F_=(t,r,i,s)=>{i.type="boolean"},U_=(t,r,i,s)=>{if(r.unrepresentable==="throw")throw new Error("BigInt cannot be represented in JSON Schema")},Z_=(t,r,i,s)=>{i.not={}},q_=(t,r,i,s)=>{},V_=(t,r,i,s)=>{const u=t._zod.def,p=gm(u.entries);p.every(d=>typeof d=="number")&&(i.type="number"),p.every(d=>typeof d=="string")&&(i.type="string"),i.enum=p},W_=(t,r,i,s)=>{const u=t._zod.def,p=[];for(const d of u.values)if(d===void 0){if(r.unrepresentable==="throw")throw new Error("Literal `undefined` cannot be represented in JSON Schema")}else if(typeof d=="bigint"){if(r.unrepresentable==="throw")throw new Error("BigInt literals cannot be represented in JSON Schema");p.push(Number(d))}else p.push(d);if(p.length!==0)if(p.length===1){const d=p[0];i.type=d===null?"null":typeof d,r.target==="draft-04"||r.target==="openapi-3.0"?i.enum=[d]:i.const=d}else p.every(d=>typeof d=="number")&&(i.type="number"),p.every(d=>typeof d=="string")&&(i.type="string"),p.every(d=>typeof d=="boolean")&&(i.type="boolean"),p.every(d=>d===null)&&(i.type="null"),i.enum=p},H_=(t,r,i,s)=>{if(r.unrepresentable==="throw")throw new Error("Custom types cannot be represented in JSON Schema")},G_=(t,r,i,s)=>{if(r.unrepresentable==="throw")throw new Error("Transforms cannot be represented in JSON Schema")},J_=(t,r,i,s)=>{const u=i,p=t._zod.def,{minimum:d,maximum:m}=t._zod.bag;typeof d=="number"&&(u.minItems=d),typeof m=="number"&&(u.maxItems=m),u.type="array",u.items=Je(p.element,r,{...s,path:[...s.path,"items"]})},K_=(t,r,i,s)=>{const u=i,p=t._zod.def;u.type="object",u.properties={};const d=p.shape;for(const y in d)u.properties[y]=Je(d[y],r,{...s,path:[...s.path,"properties",y]});const m=new Set(Object.keys(d)),g=new Set([...m].filter(y=>{const E=p.shape[y]._zod;return r.io==="input"?E.optin===void 0:E.optout===void 0}));g.size>0&&(u.required=Array.from(g)),p.catchall?._zod.def.type==="never"?u.additionalProperties=!1:p.catchall?p.catchall&&(u.additionalProperties=Je(p.catchall,r,{...s,path:[...s.path,"additionalProperties"]})):r.io==="output"&&(u.additionalProperties=!1)},Q_=(t,r,i,s)=>{const u=t._zod.def,p=u.inclusive===!1,d=u.options.map((m,g)=>Je(m,r,{...s,path:[...s.path,p?"oneOf":"anyOf",g]}));p?i.oneOf=d:i.anyOf=d},Y_=(t,r,i,s)=>{const u=t._zod.def,p=Je(u.left,r,{...s,path:[...s.path,"allOf",0]}),d=Je(u.right,r,{...s,path:[...s.path,"allOf",1]}),m=y=>"allOf"in y&&Object.keys(y).length===1,g=[...m(p)?p.allOf:[p],...m(d)?d.allOf:[d]];i.allOf=g},X_=(t,r,i,s)=>{const u=i,p=t._zod.def;u.type="object";const d=p.keyType,g=d._zod.bag?.patterns;if(p.mode==="loose"&&g&&g.size>0){const E=Je(p.valueType,r,{...s,path:[...s.path,"patternProperties","*"]});u.patternProperties={};for(const S of g)u.patternProperties[S.source]=E}else(r.target==="draft-07"||r.target==="draft-2020-12")&&(u.propertyNames=Je(p.keyType,r,{...s,path:[...s.path,"propertyNames"]})),u.additionalProperties=Je(p.valueType,r,{...s,path:[...s.path,"additionalProperties"]});const y=d._zod.values;if(y){const E=[...y].filter(S=>typeof S=="string"||typeof S=="number");E.length>0&&(u.required=E)}},e8=(t,r,i,s)=>{const u=t._zod.def,p=Je(u.innerType,r,s),d=r.seen.get(t);r.target==="openapi-3.0"?(d.ref=u.innerType,i.nullable=!0):i.anyOf=[p,{type:"null"}]},t8=(t,r,i,s)=>{const u=t._zod.def;Je(u.innerType,r,s);const p=r.seen.get(t);p.ref=u.innerType},n8=(t,r,i,s)=>{const u=t._zod.def;Je(u.innerType,r,s);const p=r.seen.get(t);p.ref=u.innerType,i.default=JSON.parse(JSON.stringify(u.defaultValue))},r8=(t,r,i,s)=>{const u=t._zod.def;Je(u.innerType,r,s);const p=r.seen.get(t);p.ref=u.innerType,r.io==="input"&&(i._prefault=JSON.parse(JSON.stringify(u.defaultValue)))},o8=(t,r,i,s)=>{const u=t._zod.def;Je(u.innerType,r,s);const p=r.seen.get(t);p.ref=u.innerType;let d;try{d=u.catchValue(void 0)}catch{throw new Error("Dynamic catch values are not supported in JSON Schema")}i.default=d},i8=(t,r,i,s)=>{const u=t._zod.def,p=u.in._zod.traits.has("$ZodTransform"),d=r.io==="input"?p?u.out:u.in:u.out;Je(d,r,s);const m=r.seen.get(t);m.ref=d},a8=(t,r,i,s)=>{const u=t._zod.def;Je(u.innerType,r,s);const p=r.seen.get(t);p.ref=u.innerType,i.readOnly=!0},Um=(t,r,i,s)=>{const u=t._zod.def;Je(u.innerType,r,s);const p=r.seen.get(t);p.ref=u.innerType},s8=j("ZodISODateTime",(t,r)=>{a3.init(t,r),Ue.init(t,r)});function N(t){return p_(s8,t)}const l8=j("ZodISODate",(t,r)=>{s3.init(t,r),Ue.init(t,r)});function u8(t){return f_(l8,t)}const c8=j("ZodISOTime",(t,r)=>{l3.init(t,r),Ue.init(t,r)});function d8(t){return m_(c8,t)}const p8=j("ZodISODuration",(t,r)=>{u3.init(t,r),Ue.init(t,r)});function f8(t){return v_(p8,t)}const m8=(t,r)=>{xm.init(t,r),t.name="ZodError",Object.defineProperties(t,{format:{value:i=>Q7(t,i)},flatten:{value:i=>K7(t,i)},addIssue:{value:i=>{t.issues.push(i),t.message=JSON.stringify(t.issues,Kl,2)}},addIssues:{value:i=>{t.issues.push(...i),t.message=JSON.stringify(t.issues,Kl,2)}},isEmpty:{get(){return t.issues.length===0}}})},Ft=j("ZodError",m8,{Parent:Error}),v8=vu(Ft),h8=hu(Ft),g8=Aa(Ft),y8=Oa(Ft),_8=e2(Ft),w8=t2(Ft),x8=n2(Ft),E8=r2(Ft),I8=o2(Ft),S8=i2(Ft),k8=a2(Ft),b8=s2(Ft),bf=new WeakMap;function ei(t,r,i){const s=Object.getPrototypeOf(t);let u=bf.get(s);if(u||(u=new Set,bf.set(s,u)),!u.has(r)){u.add(r);for(const p in i){const d=i[p];Object.defineProperty(s,p,{configurable:!0,enumerable:!1,get(){const m=d.bind(this);return Object.defineProperty(this,p,{configurable:!0,writable:!0,enumerable:!0,value:m}),m},set(m){Object.defineProperty(this,p,{configurable:!0,writable:!0,enumerable:!0,value:m})}})}}}const Le=j("ZodType",(t,r)=>(je.init(t,r),Object.assign(t["~standard"],{jsonSchema:{input:ba(t,"input"),output:ba(t,"output")}}),t.toJSONSchema=$_(t,{}),t.def=r,t.type=r.type,Object.defineProperty(t,"_def",{value:r}),t.parse=(i,s)=>v8(t,i,s,{callee:t.parse}),t.safeParse=(i,s)=>g8(t,i,s),t.parseAsync=async(i,s)=>h8(t,i,s,{callee:t.parseAsync}),t.safeParseAsync=async(i,s)=>y8(t,i,s),t.spa=t.safeParseAsync,t.encode=(i,s)=>_8(t,i,s),t.decode=(i,s)=>w8(t,i,s),t.encodeAsync=async(i,s)=>x8(t,i,s),t.decodeAsync=async(i,s)=>E8(t,i,s),t.safeEncode=(i,s)=>I8(t,i,s),t.safeDecode=(i,s)=>S8(t,i,s),t.safeEncodeAsync=async(i,s)=>k8(t,i,s),t.safeDecodeAsync=async(i,s)=>b8(t,i,s),ei(t,"ZodType",{check(...i){const s=this.def;return this.clone(Kn(s,{checks:[...s.checks??[],...i.map(u=>typeof u=="function"?{_zod:{check:u,def:{check:"custom"},onattach:[]}}:u)]}),{parent:!0})},with(...i){return this.check(...i)},clone(i,s){return Qn(this,i,s)},brand(){return this},register(i,s){return i.add(this,s),this},refine(i,s){return this.check(gw(i,s))},superRefine(i,s){return this.check(yw(i,s))},overwrite(i){return this.check(ro(i))},optional(){return Bf(this)},exactOptional(){return ow(this)},nullable(){return Rf(this)},nullish(){return Bf(Rf(this))},nonoptional(i){return cw(this,i)},array(){return P(this)},or(i){return Yn([this,i])},and(i){return X8(this,i)},transform(i){return Pf(this,nw(i))},default(i){return sw(this,i)},prefault(i){return uw(this,i)},catch(i){return pw(this,i)},pipe(i){return Pf(this,i)},readonly(){return vw(this)},describe(i){const s=this.clone();return Wo.add(s,{description:i}),s},meta(...i){if(i.length===0)return Wo.get(this);const s=this.clone();return Wo.add(s,i[0]),s},isOptional(){return this.safeParse(void 0).success},isNullable(){return this.safeParse(null).success},apply(i){return i(this)}}),Object.defineProperty(t,"description",{get(){return Wo.get(t)?.description},configurable:!0}),t)),Zm=j("_ZodString",(t,r)=>{gu.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(s,u,p)=>D_(t,s,u);const i=t._zod.bag;t.format=i.format??null,t.minLength=i.minimum??null,t.maxLength=i.maximum??null,ei(t,"_ZodString",{regex(...s){return this.check(E_(...s))},includes(...s){return this.check(k_(...s))},startsWith(...s){return this.check(b_(...s))},endsWith(...s){return this.check(z_(...s))},min(...s){return this.check(ka(...s))},max(...s){return this.check($m(...s))},length(...s){return this.check(Lm(...s))},nonempty(...s){return this.check(ka(1,...s))},lowercase(s){return this.check(I_(s))},uppercase(s){return this.check(S_(s))},trim(){return this.check(T_())},normalize(...s){return this.check(C_(...s))},toLowerCase(){return this.check(B_())},toUpperCase(){return this.check(R_())},slugify(){return this.check(P_())}})}),z8=j("ZodString",(t,r)=>{gu.init(t,r),Zm.init(t,r),t.email=i=>t.check(W3(C8,i)),t.url=i=>t.check(jm(qm,i)),t.jwt=i=>t.check(d_(Z8,i)),t.emoji=i=>t.check(Q3(T8,i)),t.guid=i=>t.check(kf(zf,i)),t.uuid=i=>t.check(H3(ya,i)),t.uuidv4=i=>t.check(G3(ya,i)),t.uuidv6=i=>t.check(J3(ya,i)),t.uuidv7=i=>t.check(K3(ya,i)),t.nanoid=i=>t.check(Y3(B8,i)),t.guid=i=>t.check(kf(zf,i)),t.cuid=i=>t.check(X3(R8,i)),t.cuid2=i=>t.check(e_(P8,i)),t.ulid=i=>t.check(t_(N8,i)),t.base64=i=>t.check(l_(M8,i)),t.base64url=i=>t.check(u_(F8,i)),t.xid=i=>t.check(n_(A8,i)),t.ksuid=i=>t.check(r_(O8,i)),t.ipv4=i=>t.check(o_(j8,i)),t.ipv6=i=>t.check(i_($8,i)),t.cidrv4=i=>t.check(a_(L8,i)),t.cidrv6=i=>t.check(s_(D8,i)),t.e164=i=>t.check(c_(U8,i)),t.datetime=i=>t.check(N(i)),t.date=i=>t.check(u8(i)),t.time=i=>t.check(d8(i)),t.duration=i=>t.check(f8(i))});function o(t){return V3(z8,t)}const Ue=j("ZodStringFormat",(t,r)=>{$e.init(t,r),Zm.init(t,r)}),C8=j("ZodEmail",(t,r)=>{Q2.init(t,r),Ue.init(t,r)}),zf=j("ZodGUID",(t,r)=>{J2.init(t,r),Ue.init(t,r)}),ya=j("ZodUUID",(t,r)=>{K2.init(t,r),Ue.init(t,r)}),qm=j("ZodURL",(t,r)=>{Y2.init(t,r),Ue.init(t,r)});function Cf(t){return jm(qm,t)}const T8=j("ZodEmoji",(t,r)=>{X2.init(t,r),Ue.init(t,r)}),B8=j("ZodNanoID",(t,r)=>{e3.init(t,r),Ue.init(t,r)}),R8=j("ZodCUID",(t,r)=>{t3.init(t,r),Ue.init(t,r)}),P8=j("ZodCUID2",(t,r)=>{n3.init(t,r),Ue.init(t,r)}),N8=j("ZodULID",(t,r)=>{r3.init(t,r),Ue.init(t,r)}),A8=j("ZodXID",(t,r)=>{o3.init(t,r),Ue.init(t,r)}),O8=j("ZodKSUID",(t,r)=>{i3.init(t,r),Ue.init(t,r)}),j8=j("ZodIPv4",(t,r)=>{c3.init(t,r),Ue.init(t,r)}),$8=j("ZodIPv6",(t,r)=>{d3.init(t,r),Ue.init(t,r)}),L8=j("ZodCIDRv4",(t,r)=>{p3.init(t,r),Ue.init(t,r)}),D8=j("ZodCIDRv6",(t,r)=>{f3.init(t,r),Ue.init(t,r)}),M8=j("ZodBase64",(t,r)=>{m3.init(t,r),Ue.init(t,r)}),F8=j("ZodBase64URL",(t,r)=>{h3.init(t,r),Ue.init(t,r)}),U8=j("ZodE164",(t,r)=>{g3.init(t,r),Ue.init(t,r)}),Z8=j("ZodJWT",(t,r)=>{_3.init(t,r),Ue.init(t,r)}),Vm=j("ZodNumber",(t,r)=>{Rm.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(s,u,p)=>M_(t,s,u),ei(t,"ZodNumber",{gt(s,u){return this.check(Sa(s,u))},gte(s,u){return this.check(Un(s,u))},min(s,u){return this.check(Un(s,u))},lt(s,u){return this.check(Ia(s,u))},lte(s,u){return this.check(Gr(s,u))},max(s,u){return this.check(Gr(s,u))},int(s){return this.check(Be(s))},safe(s){return this.check(Be(s))},positive(s){return this.check(Sa(0,s))},nonnegative(s){return this.check(Un(0,s))},negative(s){return this.check(Ia(0,s))},nonpositive(s){return this.check(Gr(0,s))},multipleOf(s,u){return this.check(Yl(s,u))},step(s,u){return this.check(Yl(s,u))},finite(){return this}});const i=t._zod.bag;t.minValue=Math.max(i.minimum??Number.NEGATIVE_INFINITY,i.exclusiveMinimum??Number.NEGATIVE_INFINITY)??null,t.maxValue=Math.min(i.maximum??Number.POSITIVE_INFINITY,i.exclusiveMaximum??Number.POSITIVE_INFINITY)??null,t.isInt=(i.format??"").includes("int")||Number.isSafeInteger(i.multipleOf??.5),t.isFinite=!0,t.format=i.format??null});function pr(t){return h_(Vm,t)}const q8=j("ZodNumberFormat",(t,r)=>{w3.init(t,r),Vm.init(t,r)});function Be(t){return g_(q8,t)}const V8=j("ZodBoolean",(t,r)=>{x3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>F_(t,i,s)});function Z(t){return y_(V8,t)}const W8=j("ZodBigInt",(t,r)=>{E3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(s,u,p)=>U_(t,s),t.gte=(s,u)=>t.check(Un(s,u)),t.min=(s,u)=>t.check(Un(s,u)),t.gt=(s,u)=>t.check(Sa(s,u)),t.gte=(s,u)=>t.check(Un(s,u)),t.min=(s,u)=>t.check(Un(s,u)),t.lt=(s,u)=>t.check(Ia(s,u)),t.lte=(s,u)=>t.check(Gr(s,u)),t.max=(s,u)=>t.check(Gr(s,u)),t.positive=s=>t.check(Sa(BigInt(0),s)),t.negative=s=>t.check(Ia(BigInt(0),s)),t.nonpositive=s=>t.check(Gr(BigInt(0),s)),t.nonnegative=s=>t.check(Un(BigInt(0),s)),t.multipleOf=(s,u)=>t.check(Yl(s,u));const i=t._zod.bag;t.minValue=i.minimum??null,t.maxValue=i.maximum??null,t.format=i.format??null}),H8=j("ZodUnknown",(t,r)=>{I3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>q_()});function Jn(){return w_(H8)}const G8=j("ZodNever",(t,r)=>{S3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>Z_(t,i,s)});function $a(t){return x_(G8,t)}const J8=j("ZodArray",(t,r)=>{k3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>J_(t,i,s,u),t.element=r.element,ei(t,"ZodArray",{min(i,s){return this.check(ka(i,s))},nonempty(i){return this.check(ka(1,i))},max(i,s){return this.check($m(i,s))},length(i,s){return this.check(Lm(i,s))},unwrap(){return this.element}})});function P(t,r){return N_(J8,t,r)}const K8=j("ZodObject",(t,r)=>{z3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>K_(t,i,s,u),ze(t,"shape",()=>r.shape),ei(t,"ZodObject",{keyof(){return Kt(Object.keys(this._zod.def.shape))},catchall(i){return this.clone({...this._zod.def,catchall:i})},passthrough(){return this.clone({...this._zod.def,catchall:Jn()})},loose(){return this.clone({...this._zod.def,catchall:Jn()})},strict(){return this.clone({...this._zod.def,catchall:$a()})},strip(){return this.clone({...this._zod.def,catchall:void 0})},extend(i){return q7(this,i)},safeExtend(i){return V7(this,i)},merge(i){return W7(this,i)},pick(i){return U7(this,i)},omit(i){return Z7(this,i)},partial(...i){return H7(Gm,this,i[0])},required(...i){return G7(Jm,this,i[0])}})});function h(t,r){const i={type:"object",shape:t??{},...ie(r)};return new K8(i)}const Wm=j("ZodUnion",(t,r)=>{Am.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>Q_(t,i,s,u),t.options=r.options});function Yn(t,r){return new Wm({type:"union",options:t,...ie(r)})}const Q8=j("ZodDiscriminatedUnion",(t,r)=>{Wm.init(t,r),C3.init(t,r)});function Hm(t,r,i){return new Q8({type:"union",options:r,discriminator:t,...ie(i)})}const Y8=j("ZodIntersection",(t,r)=>{T3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>Y_(t,i,s,u)});function X8(t,r){return new Y8({type:"intersection",left:t,right:r})}const Tf=j("ZodRecord",(t,r)=>{B3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>X_(t,i,s,u),t.keyType=r.keyType,t.valueType=r.valueType});function fe(t,r,i){return!r||!r._zod?new Tf({type:"record",keyType:o(),valueType:t,...ie(r)}):new Tf({type:"record",keyType:t,valueType:r,...ie(i)})}const Xl=j("ZodEnum",(t,r)=>{R3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(s,u,p)=>V_(t,s,u),t.enum=r.entries,t.options=Object.values(r.entries);const i=new Set(Object.keys(r.entries));t.extract=(s,u)=>{const p={};for(const d of s)if(i.has(d))p[d]=r.entries[d];else throw new Error(`Key ${d} not found in enum`);return new Xl({...r,checks:[],...ie(u),entries:p})},t.exclude=(s,u)=>{const p={...r.entries};for(const d of s)if(i.has(d))delete p[d];else throw new Error(`Key ${d} not found in enum`);return new Xl({...r,checks:[],...ie(u),entries:p})}});function Kt(t,r){const i=Array.isArray(t)?Object.fromEntries(t.map(s=>[s,s])):t;return new Xl({type:"enum",entries:i,...ie(r)})}const ew=j("ZodLiteral",(t,r)=>{P3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>W_(t,i,s),t.values=new Set(r.values),Object.defineProperty(t,"value",{get(){if(r.values.length>1)throw new Error("This schema contains multiple valid literal values. Use `.values` instead.");return r.values[0]}})});function x(t,r){return new ew({type:"literal",values:Array.isArray(t)?t:[t],...ie(r)})}const tw=j("ZodTransform",(t,r)=>{N3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>G_(t,i),t._zod.parse=(i,s)=>{if(s.direction==="backward")throw new hm(t.constructor.name);i.addIssue=p=>{if(typeof p=="string")i.issues.push(Yo(p,i.value,r));else{const d=p;d.fatal&&(d.continue=!1),d.code??(d.code="custom"),d.input??(d.input=i.value),d.inst??(d.inst=t),i.issues.push(Yo(d))}};const u=r.transform(i.value,i);return u instanceof Promise?u.then(p=>(i.value=p,i.fallback=!0,i)):(i.value=u,i.fallback=!0,i)}});function nw(t){return new tw({type:"transform",transform:t})}const Gm=j("ZodOptional",(t,r)=>{Om.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>Um(t,i,s,u),t.unwrap=()=>t._zod.def.innerType});function Bf(t){return new Gm({type:"optional",innerType:t})}const rw=j("ZodExactOptional",(t,r)=>{A3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>Um(t,i,s,u),t.unwrap=()=>t._zod.def.innerType});function ow(t){return new rw({type:"optional",innerType:t})}const iw=j("ZodNullable",(t,r)=>{O3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>e8(t,i,s,u),t.unwrap=()=>t._zod.def.innerType});function Rf(t){return new iw({type:"nullable",innerType:t})}const aw=j("ZodDefault",(t,r)=>{j3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>n8(t,i,s,u),t.unwrap=()=>t._zod.def.innerType,t.removeDefault=t.unwrap});function sw(t,r){return new aw({type:"default",innerType:t,get defaultValue(){return typeof r=="function"?r():_m(r)}})}const lw=j("ZodPrefault",(t,r)=>{$3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>r8(t,i,s,u),t.unwrap=()=>t._zod.def.innerType});function uw(t,r){return new lw({type:"prefault",innerType:t,get defaultValue(){return typeof r=="function"?r():_m(r)}})}const Jm=j("ZodNonOptional",(t,r)=>{L3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>t8(t,i,s,u),t.unwrap=()=>t._zod.def.innerType});function cw(t,r){return new Jm({type:"nonoptional",innerType:t,...ie(r)})}const dw=j("ZodCatch",(t,r)=>{D3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>o8(t,i,s,u),t.unwrap=()=>t._zod.def.innerType,t.removeCatch=t.unwrap});function pw(t,r){return new dw({type:"catch",innerType:t,catchValue:typeof r=="function"?r:()=>r})}const fw=j("ZodPipe",(t,r)=>{M3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>i8(t,i,s,u),t.in=r.in,t.out=r.out});function Pf(t,r){return new fw({type:"pipe",in:t,out:r})}const mw=j("ZodReadonly",(t,r)=>{F3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>a8(t,i,s,u),t.unwrap=()=>t._zod.def.innerType});function vw(t){return new mw({type:"readonly",innerType:t})}const hw=j("ZodCustom",(t,r)=>{U3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>H_(t,i)});function gw(t,r={}){return A_(hw,t,r)}function yw(t,r){return O_(t,r)}function w(t){return __(W8,t)}const _w=h({MaxMessageLength:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),SupportsAttachments:Z(),SupportsChildConversations:Z()}),ti=h({account_id:o(),provider:o()});h({dir:o().optional(),name:o().min(1),provider:o().min(1),scope:o().optional()});h({agent:o(),status:o()});const ww=h({agent_id:o(),parent_tool_use_id:o()});h({dir:o().optional(),env:fe(o(),o()).optional(),name:o().optional(),scope:o().optional(),suspended:Z().optional(),tmux_alias:o().optional(),work_dir:o().optional()});h({agent:o(),bytes:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),prompt:o()});h({provider:o().optional(),scope:o().optional(),suspended:Z().optional()});h({provider:o().optional(),scope:o().optional(),suspended:Z().optional()});const xw=h({dir:o().optional(),is_pool:Z().optional(),name:o(),origin:o(),provider:o().optional(),scope:o().optional(),suspended:Z()}),Ew=h({acp_args:P(o()).optional(),acp_command:o().optional(),args:P(o()).nullish(),command:o().optional(),display_name:o().optional(),env:fe(o(),o()).optional(),origin:o(),prompt_flag:o().optional(),prompt_mode:o().optional(),ready_delay_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional()});h({event_cursor:o(),request_id:o(),status:o()});h({event_cursor:o(),request_id:o()});h({assignee:o().optional()});h({reason:o().max(1024).optional()});h({assignee:o().optional(),description:o().optional(),labels:P(o()).nullish(),metadata:fe(o(),o()).optional(),parent:o().optional(),priority:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),rig:o().optional(),title:o().min(1),type:o().optional()});h({assignee:o().optional(),description:o().optional(),labels:P(o()).nullish(),metadata:fe(o(),o()).optional(),parent:o().nullish(),priority:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),remove_labels:P(o()).nullish(),status:o().optional(),title:o().optional(),type:o().optional()});const Iw=Kt(["active","ended"]),yu=h({conversation_id:o(),provider:o(),session_id:o()});h({bootstrap_profile:Kt(["k8s-cell","kubernetes","kubernetes-cell","single-host-compat"]).optional(),dir:o().min(1),provider:o().min(1).optional(),start_command:o().optional()});const _u=h({name:o(),path:o(),request_id:o()});h({agent_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),name:o(),path:o(),provider:o().optional(),rig_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_template:o().optional(),suspended:Z(),uptime_sec:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),version:o().optional()});const Sw=h({error:o().optional(),name:o(),path:o(),phases_completed:P(o()).nullish(),running:Z(),status:o().optional()}),ni=h({name:o(),path:o()});h({suspended:Z().optional()});const wu=h({name:o(),path:o(),request_id:o()}),kw=h({dir:o().optional(),is_pool:Z().optional(),name:o(),provider:o().optional(),scope:o().optional(),suspended:Z()}),bw=h({agents:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),providers:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),rigs:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({agents:P(xw).nullable(),patches:bw,providers:fe(o(),Ew)});const zw=h({agent_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),provider_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),rig_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})}),Cw=h({name:o(),path:o(),prefix:o().optional(),suspended:Z()});h({errors:P(o()).nullable(),valid:Z(),warnings:P(o()).nullable()});h({GroupID:o(),Handle:o(),ID:o(),Metadata:fe(o(),o()),Public:Z(),SessionID:o()});const Tw=Kt(["dm","room","thread"]),Yt=h({account_id:o(),conversation_id:o(),kind:Tw,parent_conversation_id:o().optional(),provider:o(),scope_id:o()});h({items:P(o()).nullish()});h({closed:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),complete:Z(),convoy_id:o(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({items:P(o()).nullish(),rig:o().optional(),title:o().min(1)});const Bw=h({closed:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({items:P(o()).nullish()});const Rw=h({BindingGeneration:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),Conversation:Yt,ID:o(),LastMessageID:o(),LastPublishedAt:N({offset:!0}),Metadata:fe(o(),o()),SchemaVersion:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),SessionID:o(),SourceSessionID:o()}),Pw=h({depends_on_id:o(),issue_id:o(),type:o()}),fr=h({assignee:o().optional(),created_at:N({offset:!0}),dependencies:P(Pw).nullish(),description:o().optional(),ephemeral:Z().optional(),from:o().optional(),id:o(),issue_type:o(),labels:P(o()).nullish(),metadata:fe(o(),o()).optional(),needs:P(o()).nullish(),parent:o().optional(),priority:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).nullish(),ref:o().optional(),status:o(),title:o(),updated_at:N({offset:!0}).optional()});h({children:P(fr).nullable()});const gr=h({bead:fr});h({children:P(fr).nullish(),convoy:fr.optional(),progress:Bw.optional()});const Nw=h({location:o().optional(),message:o().optional(),value:Jn().optional()});h({detail:o().optional(),errors:P(Nw).nullish(),instance:Cf().optional(),status:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),title:o().optional(),type:Cf().optional().default("about:blank")});h({status:o()});h({actor:o().min(1),message:o().optional(),subject:o().optional(),type:o().min(1)});const Aw=h({seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),ts:N({offset:!0}),type:o()}),Ow=h({compression_status:Kt(["pending","complete"]),first_seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),last_seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),path:o()});h({anchor_event:Aw.optional(),archive:Ow.optional(),reason:o().optional(),rotated:Z()});h({account_id:o().min(1),callback_url:o().optional(),capabilities:_w.optional(),name:o().optional(),provider:o().min(1)});h({account_id:o(),name:o(),provider:o(),status:o()});h({account_id:o().min(1),provider:o().min(1)});h({conversation:Yt.optional(),metadata:fe(o(),o()).optional(),session_id:o().min(1)});h({default_handle:o().optional(),metadata:fe(o(),o()).optional(),mode:o().optional(),root_conversation:Yt.optional()});h({conversation:Yt.optional(),idempotency_key:o().optional(),reply_to_message_id:o().optional(),session_id:o().min(1),text:o().optional()});h({group_id:o().min(1),handle:o().min(1)});h({group_id:o().min(1),handle:o().min(1),metadata:fe(o(),o()).optional(),public:Z().optional(),session_id:o().min(1)});h({conversation:Yt.optional(),sequence:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),session_id:o().min(1)});h({conversation:Yt.optional(),session_id:o().min(1)});const Km=h({display_name:o(),id:o(),is_bot:Z()}),Qm=h({mime_type:o(),provider_id:o(),url:o()}),Ym=h({actor:Km,attachments:P(Qm).nullish(),conversation:Yt,dedup_key:o().optional(),explicit_target:o().optional(),provider_message_id:o(),received_at:N({offset:!0}),reply_to_message_id:o().optional(),text:o()});h({account_id:o().optional(),message:Ym.optional(),payload:o().optional(),provider:o().optional()});const jw=h({account_id:o(),name:o(),provider:o()}),$w=h({AllowUntargetedPublication:Z(),Enabled:Z(),MaxPeerTriggeredPublishes:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),MaxTotalPeerDeliveries:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({DefaultHandle:o(),FanoutPolicy:$w,ID:o(),LastAddressedHandle:o(),Metadata:fe(o(),o()),Mode:o(),RootConversation:Yt,SchemaVersion:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({scope_kind:o().optional(),scope_ref:o().optional(),target:o().min(1),vars:fe(o(),o()).optional()});const Xm=h({from:o(),kind:o().optional(),to:o()}),Lw=h({id:o(),kind:o(),scope_ref:o().optional(),title:o()}),Dw=h({edges:P(Xm).nullable(),nodes:P(Lw).nullable()}),ev=h({started_at:o(),status:o(),target:o(),updated_at:o(),workflow_id:o()});h({formula:o(),partial:Z(),partial_errors:P(o()).nullish(),recent_runs:P(ev).nullable(),run_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const Mw=h({assignee:o().optional(),id:o(),kind:o(),labels:P(o()).nullish(),metadata:fe(o(),o()).optional(),title:o(),type:o().optional()}),tv=h({default:Jn().optional(),description:o().optional(),enum:P(o()).nullish(),name:o(),pattern:o().optional(),required:Z().optional(),type:o()});h({deps:P(Xm).nullable(),description:o(),name:o(),preview:Dw,steps:P(Mw).nullable(),var_defs:P(tv).nullable(),version:o()});const Fw=h({description:o(),name:o(),recent_runs:P(ev).nullable(),run_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),var_defs:P(tv).nullable(),version:o()});h({items:P(Fw).nullable(),partial:Z(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const Uw=h({ahead:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),behind:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),branch:o(),changed_files:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),clean:Z()}),xu=h({conversation_id:o(),mode:o(),provider:o()}),Zw=h({Match:o(),TargetSessionID:o(),UpdateCursor:Z()});h({city:o().optional(),status:o(),uptime_sec:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),version:o().optional()});const oo=h({timestamp:o()}),Eu=h({actor:o(),conversation_id:o(),provider:o(),target_session:o()});h({items:P(fr).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({items:P(jw).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const qw=fe(o(),$a());h({partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),unread:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({body:o().optional(),from:o().optional(),subject:o().optional()});h({body:o().optional(),from:o().optional(),rig:o().optional(),subject:o().min(1),to:o().min(1)});const nv=h({body:o(),cc:P(o()).nullish(),created_at:N({offset:!0}),from:o(),id:o(),priority:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),read:Z(),reply_to:o().optional(),rig:o().optional(),subject:o(),thread_id:o().optional(),to:o()}),mt=h({message:nv.optional(),rig:o()});h({items:P(nv).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const rv=h({attached_bead_id:o().optional(),bead_id:o().optional(),detail_available:Z().optional(),id:o(),logical_bead_id:o().optional(),root_bead_id:o().optional(),root_store_ref:o().optional(),run_detail_available:Z().optional(),scope_kind:o(),scope_ref:o(),started_at:o(),status:o(),store_ref:o().optional(),target:o(),title:o(),type:o(),updated_at:o(),workflow_id:o().optional()});h({items:P(rv).nullable(),partial:Z(),partial_errors:P(o()).nullish()});const ve=fe(o(),$a());h({status:o()});h({id:o().optional(),status:o()});const Vw=h({label:o(),value:o()}),Ww=h({due:Z(),last_run:o().optional(),last_run_outcome:o().optional(),name:o(),reason:o(),rig:o().optional(),scoped_name:o()});h({checks:P(Ww).nullable()});h({bead_id:o(),created_at:o(),labels:P(o()).nullable(),output:o(),store_ref:o()});const Hw=h({bead_id:o(),capture_output:Z(),created_at:o(),duration_ms:o().optional(),error:o().optional(),exit_code:o().optional(),has_output:Z(),labels:P(o()).nullable(),name:o(),rig:o().optional(),scoped_name:o(),signal:o().optional(),store_ref:o(),wisp_root_id:o().optional()});h({entries:P(Hw).nullable()});const Gw=h({capture_output:Z(),check:o().optional(),description:o().optional(),enabled:Z(),exec:o().optional(),formula:o().optional(),gate:o().optional(),interval:o().optional(),name:o(),on:o().optional(),pool:o().optional(),rig:o().optional(),schedule:o().optional(),scoped_name:o(),timeout:o().optional(),timeout_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),trigger:o().optional(),type:o()});h({orders:P(Gw).nullable()});h({items:P(rv).nullable(),partial:Z(),partial_errors:P(o()).nullish()});const Iu=h({conversation_id:o(),message_id:o(),provider:o(),session:o()}),Su=h({role:o(),text:o(),timestamp:o().optional()}),Jw=h({name:o(),path:o().optional(),ref:o().optional(),source:o().optional()});h({packs:P(Jw).nullable()});const La=h({has_older_messages:Z(),returned_message_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),total_compactions:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),total_message_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),truncated_before_message:o().optional()}),ov=h({agent:o(),format:o(),pagination:La.optional(),turns:P(Su).nullable()});h({agent_patch:o().optional(),provider_patch:o().optional(),rig_patch:o().optional(),status:o()});h({agent_patch:o().optional(),provider_patch:o().optional(),rig_patch:o().optional(),status:o()});const ku=h({kind:o(),metadata:fe(o(),o()).optional(),options:P(o()).nullish(),prompt:o().optional(),request_id:o()}),Kw=h({Check:o().nullable(),DrainTimeout:o().nullable(),Max:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).nullable(),Min:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).nullable(),OnBoot:o().nullable(),OnDeath:o().nullable()}),Qw=h({AppendFragments:P(o()).nullable(),Attach:Z().nullable(),DefaultSlingFormula:o().nullable(),DependsOn:P(o()).nullable(),Dir:o(),Env:fe(o(),o()),EnvRemove:P(o()).nullable(),HooksInstalled:Z().nullable(),IdleTimeout:o().nullable(),InjectAssignedSkills:Z().nullable(),InjectFragments:P(o()).nullable(),InjectFragmentsAppend:P(o()).nullable(),InstallAgentHooks:P(o()).nullable(),InstallAgentHooksAppend:P(o()).nullable(),Lifecycle:o().nullable(),MCP:P(o()).nullable(),MCPAppend:P(o()).nullable(),MaxActiveSessions:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).nullable(),MaxSessionAge:o().nullable(),MaxSessionAgeJitter:o().nullable(),MinActiveSessions:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).nullable(),MouseMode:o().nullable(),Name:o(),Nudge:o().nullable(),OptionDefaults:fe(o(),o()),OverlayDir:o().nullable(),Pool:Kw,PreStart:P(o()).nullable(),PreStartAppend:P(o()).nullable(),PromptTemplate:o().nullable(),Provider:o().nullable(),ResumeCommand:o().nullable(),ScaleCheck:o().nullable(),Scope:o().nullable(),Session:o().nullable(),SessionLive:P(o()).nullable(),SessionLiveAppend:P(o()).nullable(),SessionSetup:P(o()).nullable(),SessionSetupAppend:P(o()).nullable(),SessionSetupScript:o().nullable(),Skills:P(o()).nullable(),SkillsAppend:P(o()).nullable(),SleepAfterIdle:o().nullable(),StartCommand:o().nullable(),Suspended:Z().nullable(),TmuxAlias:o().nullable(),WakeMode:o().nullable(),WorkDir:o().nullable()});h({items:P(Qw).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const bu=h({host:o(),port:o(),scope_kind:o(),scope_name:o(),source:o(),user:o()}),zu=h({layer:o(),new_id:o(),old_id:o().optional(),scope_root:o(),source:o()});h({acp_args:P(o()).nullish(),acp_command:o().optional(),args:P(o()).nullish(),args_append:P(o()).nullish(),base:o().optional(),command:o().optional(),display_name:o().optional(),env:fe(o(),o()).optional(),name:o().min(1),option_defaults:fe(o(),o()).optional(),options_schema_merge:o().optional(),prompt_flag:o().optional(),prompt_mode:o().optional(),ready_delay_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional()});h({provider:o(),status:o()});const Yw=h({choices:P(Vw).nullable(),default:o(),key:o(),label:o(),type:o()}),Xw=h({ACPArgs:P(o()).nullable(),ACPCommand:o().nullable(),AcceptStartupDialogs:Z().nullable(),Args:P(o()).nullable(),ArgsAppend:P(o()).nullable(),Base:o().nullable(),Command:o().nullable(),Env:fe(o(),o()),EnvRemove:P(o()).nullable(),Name:o(),OptionsSchemaMerge:o().nullable(),PromptFlag:o().nullable(),PromptMode:o().nullable(),ReadyDelayMs:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).nullable(),Replace:Z()});h({items:P(Xw).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({accept_startup_dialogs:Z().optional(),acp_args:P(o()).nullish(),acp_command:o().optional(),args:P(o()).nullish(),command:o().optional(),env:fe(o(),o()).optional(),name:o().optional(),prompt_flag:o().optional(),prompt_mode:o().optional(),ready_delay_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional()});const e5=h({builtin:Z(),city_level:Z(),display_name:o().optional(),effective_defaults:fe(o(),o()).optional(),name:o(),options_schema:P(Yw).nullish()});h({items:P(e5).nullable(),next_cursor:o().optional(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const t5=h({detail:o().optional(),display_name:o(),status:o()});h({providers:fe(o(),t5)});const n5=h({acp_args:P(o()).optional(),acp_command:o().optional(),args:P(o()).nullish(),builtin:Z(),city_level:Z(),command:o().optional(),display_name:o().optional(),env:fe(o(),o()).optional(),name:o(),prompt_flag:o().optional(),prompt_mode:o().optional(),ready_delay_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional()});h({items:P(n5).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const r5=h({acp_args:P(o()).optional(),acp_command:o().optional(),args:P(o()).nullish(),command:o().optional(),display_name:o().optional(),env:fe(o(),o()).optional(),prompt_flag:o().optional(),prompt_mode:o().optional(),ready_delay_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional()});h({acp_args:P(o()).nullish(),acp_command:o().optional(),args:P(o()).nullish(),args_append:P(o()).nullish(),base:o().optional(),command:o().optional(),display_name:o().optional(),env:fe(o(),o()).optional(),option_defaults:fe(o(),o()).optional(),options_schema_merge:o().optional(),prompt_flag:o().optional(),prompt_mode:o().optional(),ready_delay_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional()});const o5=h({Conversation:Yt,Delivered:Z(),FailureKind:o(),MessageID:o(),Metadata:fe(o(),o()),RetryAfter:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})}),i5=h({detail:o().optional(),display_name:o(),kind:o(),name:o(),status:o()});h({items:fe(o(),i5)});const Cu=h({error_code:o(),error_message:o(),operation:Kt(["city.create","city.unregister","session.create","session.message","session.submit"]),request_id:o()});h({action:o(),failed:P(o()).nullish(),killed:P(o()).nullish(),rig:o(),status:o()});h({default_branch:o().optional(),name:o().min(1),path:o().min(1),prefix:o().optional()});h({rig:o(),status:o()});const a5=h({DefaultBranch:o().nullable(),FormulaVars:fe(o(),o()),Name:o(),Path:o().nullable(),Prefix:o().nullable(),Suspended:Z().nullable()});h({items:P(a5).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({default_branch:o().optional(),name:o().optional(),path:o().optional(),prefix:o().optional(),suspended:Z().optional()});const s5=h({agent_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),default_branch:o().optional(),git:Uw.optional(),last_activity:N({offset:!0}).optional(),name:o(),path:o(),prefix:o().optional(),running_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),suspended:Z()});h({items:P(s5).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({default_branch:o().optional(),path:o().optional(),prefix:o().optional(),suspended:Z().optional()});const Tu=h({prior_archive:o(),prior_first_seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),prior_last_seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})}),l5=fe(o(),$a());h({action:o(),service:o(),status:o()});const iv=h({activity:o()});h({messages:P(Jn()).nullable(),status:o().optional()});h({agents:P(ww).nullable()});const Bu=h({BindingGeneration:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),BoundAt:N({offset:!0}),Conversation:Yt,ExpiresAt:N({offset:!0}).nullable(),ID:o(),Metadata:fe(o(),o()),SchemaVersion:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),SessionID:o(),Status:Iw});h({unbound:P(Bu).nullable()});h({items:P(Bu).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({alias:o().optional(),async:Z().optional(),kind:o().optional(),message:o().optional(),name:o().optional(),options:fe(o(),o()).optional(),project_id:o().optional(),session_name:o().optional(),title:o().optional()});const Ru=h({bead_id:o(),bead_status:o().optional(),reason:o().optional(),session_id:o(),template:o().optional()}),u5=h({attached:Z(),last_activity:N({offset:!0}).optional(),name:o()}),c5=h({active_bead:o().optional(),activity:o().optional(),available:Z(),context_pct:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),context_window:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),description:o().optional(),display_name:o().optional(),last_output:o().optional(),model:o().optional(),name:o(),pool:o().optional(),provider:o().optional(),rig:o().optional(),running:Z(),session:u5.optional(),state:o(),suspended:Z(),unavailable_reason:o().optional()});h({items:P(c5).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const yr=h({reason:o().optional(),session_id:o(),template:o().optional()});h({message:o().min(1).regex(/\S/)});const Pu=h({request_id:o(),session_id:o()});h({alias:o().optional(),title:o().min(1).optional()});h({pending:ku.optional(),supported:Z()});h({permission_mode:o().min(1).regex(/\S/)});const av=Jn();h({title:o().min(1)});h({action:o().min(1),metadata:fe(o(),o()).optional(),request_id:o().optional(),text:o().optional()});h({id:o(),status:o()});Yn([iv,ku,oo]);const d5=h({format:o(),id:o(),pagination:La.optional(),provider:o(),template:o(),turns:P(Su).nullable()}),p5=h({format:o(),id:o(),messages:P(av).nullable(),pagination:La.optional(),provider:o(),template:o()}),Nu=h({intent:o(),queued:Z(),request_id:o(),session_id:o()});h({format:o(),id:o(),messages:P(av).nullish(),pagination:La.optional(),provider:o(),template:o(),turns:P(Su).nullish()});h({attached_bead_id:o().optional(),bead:o().optional(),force:Z().optional(),formula:o().optional(),rig:o().optional(),scope_kind:o().optional(),scope_ref:o().optional(),target:o().min(1),title:o().optional(),vars:fe(o(),o()).optional()});h({attached_bead_id:o().optional(),bead:o().optional(),formula:o().optional(),mode:o().optional(),root_bead_id:o().optional(),status:o(),target:o(),warnings:P(o()).nullish(),workflow_id:o().optional()});const f5=h({allow_websockets:Z().optional(),hostname:o().optional(),kind:o().optional(),local_state:o(),mount_path:o(),publication_state:o(),publish_mode:o(),reason:o().optional(),service_name:o(),state:o().optional(),state_root:o(),updated_at:N({offset:!0}),url:o().optional(),visibility:o().optional(),workflow_contract:o().optional()});h({items:P(f5).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const m5=h({quarantined:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),running:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),suspended:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})}),v5=h({draining:Z().optional(),expanded:Z().optional(),group_name:o().optional(),name:o(),qualified_name:o(),running:Z(),scale_label:o().optional(),scope:o(),session_name:o().optional(),suspended:Z()}),h5=h({total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),unread:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})}),g5=h({identity:o(),mode:o(),status:o()}),y5=h({suspended:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})}),_5=h({name:o(),path:o(),suspended:Z()}),w5=h({active:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),suspended:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})}),x5=h({last_gc_at:o().optional(),last_gc_status:o().optional(),live_rows:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),path:o(),ratio_mb_per_row:pr(),size_bytes:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),threshold_mb_per_row:pr(),warning:Z()}),E5=h({in_progress:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),open:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),ready:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({agent_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),agent_details:P(v5).nullish(),agents:m5,mail:h5,name:o(),named_session_details:P(g5).nullish(),partial:Z().optional(),partial_errors:P(o()).nullish(),path:o(),rig_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),rig_details:P(_5).nullish(),rigs:y5,running:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_counts_detail:w5.optional(),store_health:x5.optional(),suspended:Z(),uptime_sec:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),version:o().optional(),work:E5});const Au=h({after_bytes:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),before_bytes:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),duration_s:pr(),snapshot_path:o()}),Ou=h({duration_s:pr(),error_msg:o(),snapshot_path:o().optional(),stage:o()}),I5=h({supports_follow_up:Z(),supports_interrupt_now:Z()}),sv=h({active_bead:o().optional(),activity:o().optional(),agent_kind:o().optional(),alias:o().optional(),attached:Z(),configured_named_session:Z().optional(),context_pct:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),context_window:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),created_at:o(),display_name:o().optional(),id:o(),kind:o().optional(),last_active:o().optional(),last_nudge_delivered_at:o().optional(),last_output:o().optional(),metadata:fe(o(),o()).optional(),model:o().optional(),options:fe(o(),o()).optional(),pool:o().optional(),provider:o(),reason:o().optional(),rig:o().optional(),running:Z(),session_name:o(),state:o(),submission_capabilities:I5.optional(),template:o(),title:o()});h({items:P(sv).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const ju=h({request_id:o(),session:sv}),S5=Kt(["default","follow_up","interrupt_now"]);h({intent:S5.optional(),message:o().min(1).regex(/\S/)});h({items:P(Sw).nullable(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const $u=h({avg60:pr(),consecutive_skips:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),max_consecutive_skips:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),outcome:o(),threshold:pr(),trigger:o().optional()}),Lu=h({client_addr:o().optional(),mode:Kt(["destructive","preserve_sessions","unknown"]),signal:o().optional(),source:Kt(["signal","socket_stop"])}),k5=h({phase:o().optional(),phases_completed:P(o()).nullish(),ready:Z()});h({build_id:o().optional(),cities_running:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),cities_total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),startup:k5.optional(),status:o(),uptime_sec:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),version:o()});const b5=Kt(["inbound","outbound"]),z5=Kt(["live","hydrated"]),Du=h({Actor:Km,Attachments:P(Qm).nullable(),Conversation:Yt,CreatedAt:N({offset:!0}),ExplicitTarget:o(),ID:o(),Kind:b5,Metadata:fe(o(),o()),Provenance:z5,ProviderMessageID:o(),ReplyToMessageID:o(),SchemaVersion:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),Sequence:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),SourceSessionID:o(),Text:o()});h({Binding:Bu,GroupRoute:Zw,Message:Ym,TargetSessionID:o(),TranscriptEntry:Du});h({items:P(Du).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({DeliveryContext:Rw,Receipt:o5,TranscriptEntry:Du});const Mu=h({count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o()}),Fu=h({agent_name:o().optional(),bead_id:o().optional(),cache_creation_tokens:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),cache_read_tokens:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),completion_tokens:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),cost_usd_estimate:pr().optional(),delivered:Z().optional(),duration_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),error:o().optional(),finished_at:N({offset:!0}),latency_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),model:o().optional(),op_id:o(),operation:o(),prompt_sha:o().optional(),prompt_tokens:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),prompt_version:o().optional(),provider:o().optional(),queued:Z().optional(),result:o(),session_id:o().optional(),session_name:o().optional(),started_at:N({offset:!0}),template:o().optional(),transport:o().optional()}),lv=Yn([ti,gr,yu,_u,ni,wu,xu,Eu,mt,ve,Iu,bu,zu,Cu,Tu,ju,Ru,yr,Pu,Nu,Au,Ou,$u,Lu,Mu,Fu]),C5=h({active_attempt:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),attempt_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),max_attempts:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional()}),uv=h({assignee:o().optional(),attempt:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),id:o(),kind:o(),logical_bead_id:o().optional(),metadata:fe(o(),o()),scope_ref:o().optional(),status:o(),step_ref:o().optional(),title:o()});h({closed:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),deleted:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),partial:Z().optional(),partial_errors:P(o()).nullish(),workflow_id:o()});const eu=h({from:o(),kind:o().optional(),to:o()});h({beads:P(fr).nullable(),deps:P(eu).nullable(),root:fr});const L=h({attempt_summary:C5.optional(),bead:uv,changed_fields:P(o()).nullable(),event_seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),event_ts:o(),event_type:o(),logical_node_id:o(),requires_resync:Z().optional(),root_bead_id:o(),root_store_ref:o(),scope_kind:o(),scope_ref:o(),type:o(),watch_generation:o(),workflow_id:o(),workflow_seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({actor:o(),message:o().optional(),payload:lv.optional(),run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:o(),workflow:L.optional()});h({actor:o(),city:o(),message:o().optional(),payload:lv.optional(),run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:o(),workflow:L.optional()});const T5=h({actor:o(),message:o().optional(),payload:gr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("bead.closed"),workflow:L.optional()}),B5=h({actor:o(),message:o().optional(),payload:gr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("bead.created"),workflow:L.optional()}),R5=h({actor:o(),message:o().optional(),payload:gr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("bead.updated"),workflow:L.optional()}),P5=h({actor:o(),message:o().optional(),payload:ni,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.created"),workflow:L.optional()}),N5=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.resumed"),workflow:L.optional()}),A5=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.suspended"),workflow:L.optional()}),O5=h({actor:o(),message:o().optional(),payload:ni,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.unregister_requested"),workflow:L.optional()}),j5=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("controller.started"),workflow:L.optional()}),$5=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("controller.stopped"),workflow:L.optional()}),L5=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("convoy.closed"),workflow:L.optional()}),D5=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("convoy.created"),workflow:L.optional()}),M5=h({actor:o(),message:o().optional(),payload:Jn(),run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:o(),workflow:L.optional()}),F5=h({actor:o(),message:o().optional(),payload:Tu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("events.rotated"),workflow:L.optional()}),U5=h({actor:o(),message:o().optional(),payload:ti,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.adapter_added"),workflow:L.optional()}),Z5=h({actor:o(),message:o().optional(),payload:ti,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.adapter_removed"),workflow:L.optional()}),q5=h({actor:o(),message:o().optional(),payload:yu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.bound"),workflow:L.optional()}),V5=h({actor:o(),message:o().optional(),payload:xu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.group_created"),workflow:L.optional()}),W5=h({actor:o(),message:o().optional(),payload:Eu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.inbound"),workflow:L.optional()}),H5=h({actor:o(),message:o().optional(),payload:Iu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.outbound"),workflow:L.optional()}),G5=h({actor:o(),message:o().optional(),payload:Mu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.unbound"),workflow:L.optional()}),J5=h({actor:o(),message:o().optional(),payload:Au,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("gc.store.maintenance.done"),workflow:L.optional()}),K5=h({actor:o(),message:o().optional(),payload:Ou,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("gc.store.maintenance.failed"),workflow:L.optional()}),Q5=h({actor:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.archived"),workflow:L.optional()}),Y5=h({actor:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.deleted"),workflow:L.optional()}),X5=h({actor:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.marked_read"),workflow:L.optional()}),ex=h({actor:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.marked_unread"),workflow:L.optional()}),tx=h({actor:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.read"),workflow:L.optional()}),nx=h({actor:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.replied"),workflow:L.optional()}),rx=h({actor:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.sent"),workflow:L.optional()}),ox=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("order.completed"),workflow:L.optional()}),ix=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("order.failed"),workflow:L.optional()}),ax=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("order.fired"),workflow:L.optional()}),sx=h({actor:o(),message:o().optional(),payload:bu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("pg.credential_resolved"),workflow:L.optional()}),lx=h({actor:o(),message:o().optional(),payload:zu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("project.identity.stamped"),workflow:L.optional()}),ux=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("provider.swapped"),workflow:L.optional()}),cx=h({actor:o(),message:o().optional(),payload:Cu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.failed"),workflow:L.optional()}),dx=h({actor:o(),message:o().optional(),payload:_u,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.city.create"),workflow:L.optional()}),px=h({actor:o(),message:o().optional(),payload:wu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.city.unregister"),workflow:L.optional()}),fx=h({actor:o(),message:o().optional(),payload:ju,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.session.create"),workflow:L.optional()}),mx=h({actor:o(),message:o().optional(),payload:Pu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.session.message"),workflow:L.optional()}),vx=h({actor:o(),message:o().optional(),payload:Nu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.session.submit"),workflow:L.optional()}),hx=h({actor:o(),message:o().optional(),payload:yr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.crashed"),workflow:L.optional()}),gx=h({actor:o(),message:o().optional(),payload:Ru,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.drain_acked_with_assigned_work"),workflow:L.optional()}),yx=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.draining"),workflow:L.optional()}),_x=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.idle_killed"),workflow:L.optional()}),wx=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.max_age_killed"),workflow:L.optional()}),xx=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.quarantined"),workflow:L.optional()}),Ex=h({actor:o(),message:o().optional(),payload:yr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.stopped"),workflow:L.optional()}),Ix=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.stranded"),workflow:L.optional()}),Sx=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.suspended"),workflow:L.optional()}),kx=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.undrained"),workflow:L.optional()}),bx=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.updated"),workflow:L.optional()}),zx=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.woke"),workflow:L.optional()}),Cx=h({actor:o(),message:o().optional(),payload:yr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.work_query_failed"),workflow:L.optional()}),Tx=h({actor:o(),message:o().optional(),payload:$u,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("supervisor.fs_pressure.skipped_tick"),workflow:L.optional()}),Bx=h({actor:o(),message:o().optional(),payload:Lu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("supervisor.shutdown_requested"),workflow:L.optional()}),Rx=h({actor:o(),message:o().optional(),payload:Fu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("worker.operation"),workflow:L.optional()}),cv=Hm("type",[T5.extend({type:x("bead.closed")}),B5.extend({type:x("bead.created")}),R5.extend({type:x("bead.updated")}),P5.extend({type:x("city.created")}),N5.extend({type:x("city.resumed")}),A5.extend({type:x("city.suspended")}),O5.extend({type:x("city.unregister_requested")}),j5.extend({type:x("controller.started")}),$5.extend({type:x("controller.stopped")}),L5.extend({type:x("convoy.closed")}),D5.extend({type:x("convoy.created")}),F5.extend({type:x("events.rotated")}),U5.extend({type:x("extmsg.adapter_added")}),Z5.extend({type:x("extmsg.adapter_removed")}),q5.extend({type:x("extmsg.bound")}),V5.extend({type:x("extmsg.group_created")}),W5.extend({type:x("extmsg.inbound")}),H5.extend({type:x("extmsg.outbound")}),G5.extend({type:x("extmsg.unbound")}),J5.extend({type:x("gc.store.maintenance.done")}),K5.extend({type:x("gc.store.maintenance.failed")}),Q5.extend({type:x("mail.archived")}),Y5.extend({type:x("mail.deleted")}),X5.extend({type:x("mail.marked_read")}),ex.extend({type:x("mail.marked_unread")}),tx.extend({type:x("mail.read")}),nx.extend({type:x("mail.replied")}),rx.extend({type:x("mail.sent")}),ox.extend({type:x("order.completed")}),ix.extend({type:x("order.failed")}),ax.extend({type:x("order.fired")}),sx.extend({type:x("pg.credential_resolved")}),lx.extend({type:x("project.identity.stamped")}),ux.extend({type:x("provider.swapped")}),cx.extend({type:x("request.failed")}),dx.extend({type:x("request.result.city.create")}),px.extend({type:x("request.result.city.unregister")}),fx.extend({type:x("request.result.session.create")}),mx.extend({type:x("request.result.session.message")}),vx.extend({type:x("request.result.session.submit")}),hx.extend({type:x("session.crashed")}),gx.extend({type:x("session.drain_acked_with_assigned_work")}),yx.extend({type:x("session.draining")}),_x.extend({type:x("session.idle_killed")}),wx.extend({type:x("session.max_age_killed")}),xx.extend({type:x("session.quarantined")}),Ex.extend({type:x("session.stopped")}),Ix.extend({type:x("session.stranded")}),Sx.extend({type:x("session.suspended")}),kx.extend({type:x("session.undrained")}),bx.extend({type:x("session.updated")}),zx.extend({type:x("session.woke")}),Cx.extend({type:x("session.work_query_failed")}),Tx.extend({type:x("supervisor.fs_pressure.skipped_tick")}),Bx.extend({type:x("supervisor.shutdown_requested")}),Rx.extend({type:x("worker.operation")}),M5.extend({type:x("TypedEventStreamEnvelopeCustom")})]);h({items:P(cv).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const Px=h({actor:o(),city:o(),message:o().optional(),payload:gr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("bead.closed"),workflow:L.optional()}),Nx=h({actor:o(),city:o(),message:o().optional(),payload:gr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("bead.created"),workflow:L.optional()}),Ax=h({actor:o(),city:o(),message:o().optional(),payload:gr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("bead.updated"),workflow:L.optional()}),Ox=h({actor:o(),city:o(),message:o().optional(),payload:ni,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.created"),workflow:L.optional()}),jx=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.resumed"),workflow:L.optional()}),$x=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.suspended"),workflow:L.optional()}),Lx=h({actor:o(),city:o(),message:o().optional(),payload:ni,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.unregister_requested"),workflow:L.optional()}),Dx=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("controller.started"),workflow:L.optional()}),Mx=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("controller.stopped"),workflow:L.optional()}),Fx=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("convoy.closed"),workflow:L.optional()}),Ux=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("convoy.created"),workflow:L.optional()}),Zx=h({actor:o(),city:o(),message:o().optional(),payload:Jn(),run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:o(),workflow:L.optional()}),qx=h({actor:o(),city:o(),message:o().optional(),payload:Tu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("events.rotated"),workflow:L.optional()}),Vx=h({actor:o(),city:o(),message:o().optional(),payload:ti,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.adapter_added"),workflow:L.optional()}),Wx=h({actor:o(),city:o(),message:o().optional(),payload:ti,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.adapter_removed"),workflow:L.optional()}),Hx=h({actor:o(),city:o(),message:o().optional(),payload:yu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.bound"),workflow:L.optional()}),Gx=h({actor:o(),city:o(),message:o().optional(),payload:xu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.group_created"),workflow:L.optional()}),Jx=h({actor:o(),city:o(),message:o().optional(),payload:Eu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.inbound"),workflow:L.optional()}),Kx=h({actor:o(),city:o(),message:o().optional(),payload:Iu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.outbound"),workflow:L.optional()}),Qx=h({actor:o(),city:o(),message:o().optional(),payload:Mu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.unbound"),workflow:L.optional()}),Yx=h({actor:o(),city:o(),message:o().optional(),payload:Au,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("gc.store.maintenance.done"),workflow:L.optional()}),Xx=h({actor:o(),city:o(),message:o().optional(),payload:Ou,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("gc.store.maintenance.failed"),workflow:L.optional()}),eE=h({actor:o(),city:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.archived"),workflow:L.optional()}),tE=h({actor:o(),city:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.deleted"),workflow:L.optional()}),nE=h({actor:o(),city:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.marked_read"),workflow:L.optional()}),rE=h({actor:o(),city:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.marked_unread"),workflow:L.optional()}),oE=h({actor:o(),city:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.read"),workflow:L.optional()}),iE=h({actor:o(),city:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.replied"),workflow:L.optional()}),aE=h({actor:o(),city:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.sent"),workflow:L.optional()}),sE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("order.completed"),workflow:L.optional()}),lE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("order.failed"),workflow:L.optional()}),uE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("order.fired"),workflow:L.optional()}),cE=h({actor:o(),city:o(),message:o().optional(),payload:bu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("pg.credential_resolved"),workflow:L.optional()}),dE=h({actor:o(),city:o(),message:o().optional(),payload:zu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("project.identity.stamped"),workflow:L.optional()}),pE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("provider.swapped"),workflow:L.optional()}),fE=h({actor:o(),city:o(),message:o().optional(),payload:Cu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.failed"),workflow:L.optional()}),mE=h({actor:o(),city:o(),message:o().optional(),payload:_u,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.city.create"),workflow:L.optional()}),vE=h({actor:o(),city:o(),message:o().optional(),payload:wu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.city.unregister"),workflow:L.optional()}),hE=h({actor:o(),city:o(),message:o().optional(),payload:ju,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.session.create"),workflow:L.optional()}),gE=h({actor:o(),city:o(),message:o().optional(),payload:Pu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.session.message"),workflow:L.optional()}),yE=h({actor:o(),city:o(),message:o().optional(),payload:Nu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.session.submit"),workflow:L.optional()}),_E=h({actor:o(),city:o(),message:o().optional(),payload:yr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.crashed"),workflow:L.optional()}),wE=h({actor:o(),city:o(),message:o().optional(),payload:Ru,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.drain_acked_with_assigned_work"),workflow:L.optional()}),xE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.draining"),workflow:L.optional()}),EE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.idle_killed"),workflow:L.optional()}),IE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.max_age_killed"),workflow:L.optional()}),SE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.quarantined"),workflow:L.optional()}),kE=h({actor:o(),city:o(),message:o().optional(),payload:yr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.stopped"),workflow:L.optional()}),bE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.stranded"),workflow:L.optional()}),zE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.suspended"),workflow:L.optional()}),CE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.undrained"),workflow:L.optional()}),TE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.updated"),workflow:L.optional()}),BE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.woke"),workflow:L.optional()}),RE=h({actor:o(),city:o(),message:o().optional(),payload:yr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.work_query_failed"),workflow:L.optional()}),PE=h({actor:o(),city:o(),message:o().optional(),payload:$u,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("supervisor.fs_pressure.skipped_tick"),workflow:L.optional()}),NE=h({actor:o(),city:o(),message:o().optional(),payload:Lu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("supervisor.shutdown_requested"),workflow:L.optional()}),AE=h({actor:o(),city:o(),message:o().optional(),payload:Fu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("worker.operation"),workflow:L.optional()}),dv=Hm("type",[Px.extend({type:x("bead.closed")}),Nx.extend({type:x("bead.created")}),Ax.extend({type:x("bead.updated")}),Ox.extend({type:x("city.created")}),jx.extend({type:x("city.resumed")}),$x.extend({type:x("city.suspended")}),Lx.extend({type:x("city.unregister_requested")}),Dx.extend({type:x("controller.started")}),Mx.extend({type:x("controller.stopped")}),Fx.extend({type:x("convoy.closed")}),Ux.extend({type:x("convoy.created")}),qx.extend({type:x("events.rotated")}),Vx.extend({type:x("extmsg.adapter_added")}),Wx.extend({type:x("extmsg.adapter_removed")}),Hx.extend({type:x("extmsg.bound")}),Gx.extend({type:x("extmsg.group_created")}),Jx.extend({type:x("extmsg.inbound")}),Kx.extend({type:x("extmsg.outbound")}),Qx.extend({type:x("extmsg.unbound")}),Yx.extend({type:x("gc.store.maintenance.done")}),Xx.extend({type:x("gc.store.maintenance.failed")}),eE.extend({type:x("mail.archived")}),tE.extend({type:x("mail.deleted")}),nE.extend({type:x("mail.marked_read")}),rE.extend({type:x("mail.marked_unread")}),oE.extend({type:x("mail.read")}),iE.extend({type:x("mail.replied")}),aE.extend({type:x("mail.sent")}),sE.extend({type:x("order.completed")}),lE.extend({type:x("order.failed")}),uE.extend({type:x("order.fired")}),cE.extend({type:x("pg.credential_resolved")}),dE.extend({type:x("project.identity.stamped")}),pE.extend({type:x("provider.swapped")}),fE.extend({type:x("request.failed")}),mE.extend({type:x("request.result.city.create")}),vE.extend({type:x("request.result.city.unregister")}),hE.extend({type:x("request.result.session.create")}),gE.extend({type:x("request.result.session.message")}),yE.extend({type:x("request.result.session.submit")}),_E.extend({type:x("session.crashed")}),wE.extend({type:x("session.drain_acked_with_assigned_work")}),xE.extend({type:x("session.draining")}),EE.extend({type:x("session.idle_killed")}),IE.extend({type:x("session.max_age_killed")}),SE.extend({type:x("session.quarantined")}),kE.extend({type:x("session.stopped")}),bE.extend({type:x("session.stranded")}),zE.extend({type:x("session.suspended")}),CE.extend({type:x("session.undrained")}),TE.extend({type:x("session.updated")}),BE.extend({type:x("session.woke")}),RE.extend({type:x("session.work_query_failed")}),PE.extend({type:x("supervisor.fs_pressure.skipped_tick")}),NE.extend({type:x("supervisor.shutdown_requested")}),AE.extend({type:x("worker.operation")}),Zx.extend({type:x("TypedTaggedEventStreamEnvelopeCustom")})]);h({event_cursor:o(),items:P(dv).nullable(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({beads:P(uv).nullable(),deps:P(eu).nullable(),logical_edges:P(eu).nullable(),logical_nodes:P(qw).nullable(),partial:Z(),resolved_root_store:o(),root_bead_id:o(),root_store_ref:o(),scope_groups:P(l5).nullable(),scope_kind:o(),scope_ref:o(),snapshot_event_seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),snapshot_version:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),stores_scanned:P(o()).nullable(),workflow_id:o()});const OE=h({declared_name:o().optional(),declared_prefix:o().optional(),name:o(),prefix:o().optional(),provider:o().optional(),session_template:o().optional(),suspended:Z()});h({agents:P(kw).nullable(),patches:zw.optional(),providers:fe(o(),r5).optional(),rigs:P(Cw).nullable(),workspace:OE});P(Yn([h({data:oo,event:x("heartbeat"),id:Be().optional(),retry:Be().optional()}),h({data:ov,event:x("turn"),id:Be().optional(),retry:Be().optional()})]));P(Yn([h({data:oo,event:x("heartbeat"),id:Be().optional(),retry:Be().optional()}),h({data:ov,event:x("turn"),id:Be().optional(),retry:Be().optional()})]));fe(o(),o());P(Yn([h({data:cv,event:x("event"),id:Be().optional(),retry:Be().optional()}),h({data:oo,event:x("heartbeat"),id:Be().optional(),retry:Be().optional()})]));P(Yn([h({data:iv,event:x("activity"),id:Be().optional(),retry:Be().optional()}),h({data:oo,event:x("heartbeat"),id:Be().optional(),retry:Be().optional()}),h({data:p5,event:x("message").optional(),id:Be().optional(),retry:Be().optional()}),h({data:ku,event:x("pending"),id:Be().optional(),retry:Be().optional()}),h({data:d5,event:x("turn"),id:Be().optional(),retry:Be().optional()})]));P(Yn([h({data:oo,event:x("heartbeat"),id:o().optional(),retry:Be().optional()}),h({data:dv,event:x("tagged_event"),id:o().optional(),retry:Be().optional()})]));class Wn extends Error{constructor(r,i,s){super(i),this.status=r,this.requestId=s}status;requestId;name="SupervisorApiError"}async function Ie(t,r){let i;try{i=await t}catch(p){throw jE(p)}const{response:s}=i;if(s===void 0)throw new Wn(void 0,tu(i.error),void 0);if(!s.ok||i.error!==void 0)throw new Wn(s.status,tu(i.error,s.statusText),s.headers.get("x-gc-request-id")??void 0);const u=i.data;if(u===void 0)throw new Wn(s.status,r,s.headers.get("x-gc-request-id")??void 0);return u}function jE(t){return t instanceof Wn?t:new Wn(void 0,tu(t),void 0)}function tu(t,r="gc supervisor request failed"){if(typeof t=="string"&&t.trim().length>0)return t.trim();if(t instanceof Error&&t.message.trim().length>0)return t.message.trim();if($E(t))for(const i of["error","message","detail"]){const s=t[i];if(typeof s=="string"&&s.trim().length>0)return s.trim()}return r}function $E(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)}const LE="";function DE(){const t=globalThis.location?.origin;return typeof t=="string"&&t.length>0&&t!=="null"?t:LE}function ME(t){if(!t.startsWith("/"))return t;const r=globalThis.location?.origin;return typeof r!="string"||r.length===0||r==="null"?t:new URL(t,r).toString().replace(/\/$/,"")}function Nf(t,r,i){const s=t.replace(/\/$/,""),u=new URLSearchParams(i).toString(),p=u.length>0?`${r}?${u}`:r;return s===""?p:s.startsWith("/")?`${s}${p}`:new URL(p,`${s}/`).toString()}const FE=6e4,Rt={"X-GC-Request":"dashboard"};let Af=null;const Of=new Map;function pv(t={}){const r=t.baseUrl??DE(),s={baseUrl:ME(r),headers:{Accept:"application/json"},responseStyle:"fields",throwOnError:!1},u=t.client??vm({...s,fetch:ZE(t.fetch??globalThis.fetch,fv(t.timeoutMs))});return{baseUrl:r,health(){return Ie(i7({client:u}),"gc supervisor health response was empty")},cityHealth(p){return Ie(w7({client:u,path:{cityName:p}}),"gc supervisor city health response was empty")},cityStatus(p){return Ie(A7({client:u,path:{cityName:p}}),"gc supervisor status response was empty")},listCities(){return Ie(a7({client:u}),"gc supervisor cities response was empty")},listAgents(p){return Ie(d7({client:u,path:{cityName:p}}),"gc supervisor agents response was empty")},listRigs(p){return Ie(C7({client:u,path:{cityName:p}}),"gc supervisor rigs response was empty")},listBeads(p,d){return Ie(v7({client:u,path:{cityName:p},...d===void 0?{}:{query:d}}),"gc supervisor beads response was empty")},listEvents(p,d){return Ie(g7({client:u,path:{cityName:p},...d===void 0?{}:{query:d}}),"gc supervisor events response was empty")},getBead(p,d){return Ie(p7({client:u,path:{cityName:p,id:d}}),"gc supervisor bead response was empty")},createBead(p,d){return Ie(h7({client:u,path:{cityName:p},headers:Rt,body:d}),"gc supervisor bead create response was empty")},updateBead(p,d,m){return Ie(f7({client:u,path:{cityName:p,id:d},headers:Rt,body:m}),"gc supervisor bead update response was empty")},closeBead(p,d,m){return Ie(m7({client:u,path:{cityName:p,id:d},headers:Rt,...m===void 0?{}:{body:m}}),"gc supervisor bead close response was empty")},nudgeAgent(p,d){const m=jf(d);return"dir"in m?Ie(c7({client:u,path:{cityName:p,dir:m.dir,base:m.base,action:"nudge"},headers:Rt}),"gc supervisor agent nudge response was empty"):Ie(l7({client:u,path:{cityName:p,base:m.base,action:"nudge"},headers:Rt}),"gc supervisor agent nudge response was empty")},agentPrime(p,d){const m=jf(d);return"dir"in m?Ie(u7({client:u,path:{cityName:p,dir:m.dir,base:m.base}}),"gc supervisor agent prime response was empty"):Ie(s7({client:u,path:{cityName:p,base:m.base}}),"gc supervisor agent prime response was empty")},sling(p,d){return Ie(N7({client:u,path:{cityName:p},headers:Rt,body:d}),"gc supervisor sling response was empty")},listMail(p,d){return Ie(x7({client:u,path:{cityName:p},...d===void 0?{}:{query:d}}),"gc supervisor mail response was empty")},formulaFeed(p,d){return Ie(y7({client:u,path:{cityName:p},...d===void 0?{}:{query:d}}),"gc supervisor formula feed response was empty")},sendMail(p,d){return Ie(E7({client:u,path:{cityName:p},headers:Rt,body:d}),"gc supervisor mail send response was empty")},mailThread(p,d){return Ie(I7({client:u,path:{cityName:p,id:d}}),"gc supervisor mail thread response was empty")},markMailRead(p,d,m){return Ie(b7({client:u,path:{cityName:p,id:d},headers:Rt,...m===void 0?{}:{query:m}}),"gc supervisor mail mark-read response was empty")},markMailUnread(p,d,m){return Ie(k7({client:u,path:{cityName:p,id:d},headers:Rt,...m===void 0?{}:{query:m}}),"gc supervisor mail mark-unread response was empty")},archiveMail(p,d,m){return Ie(S7({client:u,path:{cityName:p,id:d},headers:Rt,...m===void 0?{}:{query:m}}),"gc supervisor mail archive response was empty")},replyMail(p,d,m,g){return Ie(z7({client:u,path:{cityName:p,id:d},headers:Rt,body:m,...g===void 0?{}:{query:g}}),"gc supervisor mail reply response was empty")},cityEventStreamUrl(p,d){return Nf(r,`/v0/city/${encodeURIComponent(p)}/events/stream`,d===void 0?void 0:{after_seq:d})},sessionStreamUrl(p,d,m){return Nf(r,`/v0/city/${encodeURIComponent(p)}/session/${encodeURIComponent(d)}/stream`,m===void 0?void 0:{after:m})},listSessions(p){return Ie(P7({client:u,path:{cityName:p}}),"gc supervisor sessions response was empty")},sessionPending(p,d){return Ie(T7({client:u,path:{cityName:p,id:d}}),"gc supervisor session pending response was empty")},respondSession(p,d,m){return Ie(B7({client:u,path:{cityName:p,id:d},headers:Rt,body:m}),"gc supervisor session respond response was empty")},sessionTranscript(p,d){return Ie(R7({client:u,path:{cityName:p,id:d},query:{format:"conversation"}}),"gc supervisor transcript response was empty")},workflowRun(p,d,m){return Ie(O7({client:u,path:{cityName:p,workflow_id:d},...m===void 0?{}:{query:m}}),"gc supervisor workflow response was empty")},formulaDetail(p,d,m){return Ie(_7({client:u,path:{cityName:p,name:d},query:m}),"gc supervisor formula detail response was empty")},mutationHeaders(){return{...Rt}}}}function ot(){return Af??=pv(),Af}function UE(t){const r=fv(t),i=Of.get(r);if(i!==void 0)return i;const s=pv({timeoutMs:r});return Of.set(r,s),s}function jf(t){const r=t.trim().split("/");if(r.length===1){const i=r[0];if(i!==void 0&&i!=="")return{base:i}}if(r.length===2){const i=r[0],s=r[1];if(i!==void 0&&i!==""&&s!==void 0&&s!=="")return{dir:i,base:s}}throw new Error(`invalid agent alias: ${t}`)}function fv(t){return typeof t=="number"&&Number.isFinite(t)&&t>0?t:FE}function ZE(t,r){return async(i,s)=>{const u=new AbortController,p=new Wn(void 0,`gc supervisor request timed out after ${r}ms`,void 0),d=qE(i,s);d?.aborted&&u.abort(d.reason);const m=()=>u.abort(d?.reason);d?.addEventListener("abort",m,{once:!0});let g;const y=new Promise((T,A)=>{g=setTimeout(()=>{u.abort(p),A(p)},r)}),E=new Request(i,{...s,signal:u.signal}),S=t(E);try{return await Promise.race([S,y])}finally{g!==void 0&&clearTimeout(g),d?.removeEventListener("abort",m)}}}function qE(t,r){return r?.signal!==void 0?r.signal:t instanceof Request?t.signal:null}async function VE(t,r){const i=xn("list agent pending interactions"),s=WE(r),u=t.flatMap(d=>{const m=d.session?.name;if(m===void 0)return[];const g=s.get(m);return g===void 0?[]:[{agentName:d.name,sessionId:g,sessionName:m}]});return(await Promise.all(u.map(async d=>{const m=await ot().sessionPending(i,d.sessionId);return m.pending===void 0?null:{...d,pending:m.pending}}))).filter(d=>d!==null)}async function j6(t,r){const i=xn("respond to agent pending interaction");return ot().respondSession(i,t,r)}function $6(t){return`gc agent attach ${HE(t)}`}function WE(t){const r=new Map;for(const i of t)i.session_name!==void 0&&r.set(i.session_name,i.id);return r}function HE(t){return/^[A-Za-z0-9_./:-]+$/.test(t)?t:`'${t.replaceAll("'","'\\''")}'`}const GE=1e3,JE=200,KE=1e3,QE=new Set(["feature","bug","task","epic","chore","decision"]);async function YE(t={}){const r=xn("list supervisor beads"),i=t.limit??GE,s=t.rigFilter?.trim()??"",u=t.includeClosed??!1,p=t.includeBookkeeping??!1,d={limit:i,...u?{all:!0}:{},...s.length===0?{}:{rig:s}},m=await ot().listBeads(r,d),g=vv(m.items??[]),y=u?g:g.filter(T=>T.status!=="closed"),E=p?y:y.filter(XE),S=mv(m.total);return{items:E,total:E.length,...S===void 0?{}:{upstream_total:S},upstream_fetched:g.length,fetch_limit:i}}async function L6(t,r={}){const i=xn("list supervisor assigned beads"),s=t4(t),u=r.limit??JE,p=r.includeClosed??!1;if(s.length===0)return{items:[],total:0,upstream_fetched:0,fetch_limit:u};const d=await Promise.all(s.map(y=>ot().listBeads(i,{assignee:y,limit:u,...p?{all:!0}:{}}))),m=vv(d.flatMap(y=>y.items??[])),g=e4(d);return{items:m,total:m.length,...g===void 0?{}:{upstream_total:g},upstream_fetched:m.length,fetch_limit:u}}async function D6(t){const r=xn("fetch supervisor bead");try{return await ot().getBead(r,t)}catch(i){if(!(i instanceof Wn)||i.status!==404)throw i;const u=((await ot().listBeads(r,{limit:KE})).items??[]).find(p=>p.id===t);if(u!==void 0)return u;throw i}}function XE(t){return!(!QE.has(t.issue_type)||Array.isArray(t.labels)&&t.labels.some(r=>r.startsWith("gc:")))}function mv(t){if(typeof t=="number")return t;if(typeof t=="bigint")return Number(t)}function e4(t){let r=0;for(const i of t){const s=mv(i.total);if(s===void 0)return;r+=s}return r}function vv(t){const r=new Set,i=[];for(const s of t)r.has(s.id)||(r.add(s.id),i.push(s));return i}function t4(t){const r=new Set,i=[];for(const s of t){const u=s.trim();u.length===0||r.has(u)||(r.add(u),i.push(u))}return i}const M6=[100,500,1e3],Uu=100,F6=["24h","7d","all"],n4="all",r4={"24h":1440*60*1e3,"7d":10080*60*1e3};async function Zu(t,r,i,s=Uu,u=n4,p=Date.now()){const d=xn("list supervisor mail"),m=await ot().listMail(d,{limit:s}),g=m.items??[],y=i4(o4(g,t,r,i),u,p);return y.sort(l4),{...m,items:y,total:y.length,upstream_total:g.length,upstream_fetched:g.length,fetch_limit:s}}async function U6(t,r,i,s=Uu){const u=xn("fetch supervisor mail thread");try{const p=await ot().mailThread(u,t);return $f(p)}catch(p){if(!(p instanceof Wn)||p.status!==404)throw p;const d=await Zu("all",r,i,s),m=d.items.filter(g=>g.thread_id===t);return $f({...d,items:m,total:m.length})}}function $f(t){const r=s4(t.items??[]).sort(u4);return{...t,items:r,total:r.length}}function o4(t,r,i,s){const u=a4(i,s);return r==="all"?[...t]:r==="inbox"?t.filter(p=>p.to.toLowerCase()===u):t.filter(p=>p.from.toLowerCase()===u)}function i4(t,r,i){if(r==="all")return[...t];const s=i-r4[r];return t.filter(u=>{const p=Date.parse(u.created_at);return Number.isFinite(p)&&p>=s})}function a4(t,r){const i=t.toLowerCase();return i===r.operatorAlias.toLowerCase()?r.operatorWireAlias:i}function s4(t){const r=new Set,i=[];for(const s of t)r.has(s.id)||(r.add(s.id),i.push(s));return i}function l4(t,r){return r.created_at.localeCompare(t.created_at)}function u4(t,r){return t.created_at.localeCompare(r.created_at)}function hv(t,r){if(t===void 0||t.length===0)return null;const i=Date.parse(t);if(!Number.isFinite(i))return null;const s=r-i;return s>=0?s:null}function gv(t){const r=Math.max(1,Math.round(t/36e5));return r<48?`${r}h`:`${Math.round(r/24)}d`}const c4=1440*60*1e3,d4=4320*60*1e3;function p4(t,r){const i=[];for(const s of t.escalations){const u=f4(s);u!==null&&i.push(u)}for(const s of t.beads){const u=m4(s,r);u!==null&&i.push(u)}return i}function f4(t){return t.status==="closed"?null:{beadId:t.id,reason:"escalated",severity:"attention",summary:`${t.title} — escalation raised`,updatedAt:t.updated_at??t.created_at}}function m4(t,r){if(t.status!=="open"||v4(t))return null;const i=hv(t.created_at,r);if(i===null||i=d4;return{beadId:t.id,reason:"ready-unclaimed",severity:s?"attention":"watch",summary:`${t.title} opened ${gv(i)} ago`,updatedAt:t.created_at}}function v4(t){return t.assignee!==void 0&&t.assignee.trim().length>0}function Lf(t,r){const i=`/runs/${encodeURIComponent(t)}`;if(r.status!=="available")return i;const s=new URLSearchParams;return s.set("scope_kind",r.kind),s.set("scope_ref",r.ref),`${i}?${s.toString()}`}const h4={"awaiting-input":"awaiting input",errored:"errored","rate-limited":"rate limited",stalled:"stalled"},g4={respond:"Respond to its prompt.",reset:"Reset the agent.",nudge:"Nudge it to resume."},y4={"awaiting-input":"stuck",errored:"stuck","rate-limited":"warn",stalled:"warn"};function _4(t){return h4[t]}function Z6(t){return g4[t]}function q6(t){return y4[t]}const w4=new Set(["gc.store.maintenance.failed","order.failed","request.failed","session.crashed","session.stranded","session.work_query_failed","supervisor.shutdown_requested"]),x4=new Set(["events.rotated","session.quarantined","session.suspended","supervisor.fs_pressure.skipped_tick"]);function E4(t){return w4.has(t.type)?"attention":x4.has(t.type)?"watch":"event"}function I4(t){return t.message??t.subject??t.type}const S4=1440*60*1e3,k4=30,b4=2e9,z4=1e9,C4=1e9,T4=512e6,B4="gc:escalation",R4="decision.decide";function P4(t={}){return Xo.map(r=>N4(r,t))}function N4(t,r){switch(t){case"activity":return D4(r.activity);case"agents":return j4(r.agents);case"beads":return $4(r.beads);case"health":return A4(r.health);case"mail":return L4(r.mail);case"runs":return O4(r.runs)}}function A4(t){return{id:"health:derived",domain:"health",getItems:()=>Q4(t)}}function O4(t){return{id:"runs:derived",domain:"runs",getItems:()=>M4(t)}}function j4(t){return{id:"agents:derived",domain:"agents",getItems:()=>F4(t)}}function $4(t){return{id:"beads:derived",domain:"beads",getItems:()=>U4(t)}}function L4(t){return{id:"mail:derived",domain:"mail",getItems:()=>W4(t)}}function D4(t){return{id:"activity:derived",domain:"activity",getItems:()=>G4(t)}}function M4(t){const r=[];if(t===void 0)return r;const i={provenance:t.provenance,fetchedAt:t.fetchedAt};if(t.error!==void 0&&t.error.length>0)return r.push(It("runs",{id:"runs:unavailable",title:"Run data unavailable",summary:t.error,href:"/runs"})),r;const s=t.summary;if(s===void 0)return r;s.lanesPartial===!0&&r.push(Ho("runs",{id:"runs:partial",title:"Run list incomplete",href:"/runs"},i));for(const u of[...s.lanes,...s.blockedLanes])u.health.status!=="available"&&r.push(Ho("runs",{id:`runs:${u.id}:health-unavailable`,title:`${u.title} health unavailable`,summary:u.health.error,href:Lf(u.id,u.scope)},i));for(const u of oy(s.blockedLanes))r.push(It("runs",{id:`runs:${u.id}:blocked`,title:`${u.title} blocked`,summary:u.reason,href:Lf(u.id,u.scope)}));return r}function F4(t){const r=[];if(t===void 0)return r;if(t.error!==void 0&&t.error.length>0)return r.push(Ho("agents",{id:"agents:unavailable",title:"Agent data unavailable",summary:t.error,href:"/agents"})),r;t.partial===!0&&r.push(Ho("agents",{id:"agents:partial",title:"Agent list incomplete",href:"/agents"})),t.pendingError!==void 0&&t.pendingError.length>0&&r.push(Ho("agents",{id:"agents:pending-unavailable",title:"Agent pending state unavailable",summary:t.pendingError,href:"/agents"}));const i=(t.pendingInteractions??[]).map(s=>({agentName:s.agentName,...s.pending.prompt===void 0?{}:{prompt:s.pending.prompt}}));for(const s of X0(t.items??[],i))r.push(It("agents",{id:`agents:${s.name}:needs-you`,title:`${s.name} ${_4(s.reason)}`,summary:s.detail,href:`/agents/${encodeURIComponent(s.name)}`}));return r}function U4(t){const r=[];if(t===void 0)return r;t.error!==void 0&&t.error.length>0&&r.push(It("beads",{id:"beads:unavailable",title:"Bead data unavailable",summary:t.error,href:"/beads"})),t.partial===!0&&r.push(qn("beads",{id:"beads:partial",title:"Bead list incomplete",href:"/beads"})),t.decisionsError!==void 0&&t.decisionsError.length>0&&r.push(It("beads",{id:"beads:decisions-unavailable",title:"Decision queue unavailable",summary:t.decisionsError,href:"/beads"})),t.escalationsError!==void 0&&t.escalationsError.length>0&&r.push(It("beads",{id:"beads:escalations-unavailable",title:"Escalation queue unavailable",summary:t.escalationsError,href:"/beads"}));for(const u of t.decisions??[])r.push(V4(u));const i=t.nowMs??Date.now(),s=(t.items??[]).filter(u=>!q4(u,t.decisionLabel));for(const u of p4({beads:s,escalations:t.escalations??[]},i)){const p=u.severity==="attention"?It:qn;r.push(p("beads",{id:`beads:${u.beadId}:${u.reason}`,title:`${u.beadId} ${Z4(u.reason)}`,summary:u.summary,href:yv(u.beadId),updatedAt:u.updatedAt}))}return r}function Z4(t){return t==="escalated"?"escalated":"unclaimed"}function yv(t){const r=new URLSearchParams;return r.set("bead",t),`/beads?${r.toString()}`}function q4(t,r){return(t.labels??[]).includes(r)}function V4(t){const r=t.metadata?.[R4];return It("beads",{id:`beads:${t.id}:mayor-decision`,title:t.title,href:yv(t.id),updatedAt:t.updated_at??t.created_at,...r!==void 0&&r.trim().length>0?{summary:r}:{}})}function W4(t){const r=[];if(t===void 0)return r;t.error!==void 0&&t.error.length>0&&r.push(It("mail",{id:"mail:unavailable",title:"Mail data unavailable",summary:t.error,href:"/mail"})),t.partial===!0&&r.push(qn("mail",{id:"mail:partial",title:"Mail list incomplete",href:"/mail"}));const i=t.nowMs??Date.now();for(const s of py(t.items??[])){const u=hv(s.created_at,i),p=u!==null&&u>=S4;r.push(It("mail",{id:`mail:${s.id}:${p?"unread-stale":"unread"}`,title:s.subject,summary:p?`from ${s.from}, unread for ${gv(u)}`:`from ${s.from}`,href:H4(s.id),updatedAt:s.created_at}))}return r}function H4(t){const r=new URLSearchParams;return r.set("message",t),`/mail?${r.toString()}`}function G4(t){const r=[];if(t===void 0)return r;t.deploysError!==void 0&&t.deploysError.length>0&&r.push(It("activity",{id:"activity:deploys-unavailable",title:"Deploy data unavailable",summary:t.deploysError,href:"/activity"})),t.eventsDegraded!==void 0&&t.eventsDegraded.length>0&&r.push(qn("activity",{id:"activity:events-degraded",title:"Event stream degraded",summary:t.eventsDegraded,href:"/activity"})),t.eventsError!==void 0&&t.eventsError.length>0&&r.push(qn("activity",{id:"activity:events-unavailable",title:"Event history unavailable",summary:t.eventsError,href:"/activity"})),t.eventsPartial===!0&&r.push(qn("activity",{id:"activity:events-partial",title:"Event history incomplete",href:"/activity"})),J4(r,t.events??[]);const i=t.deploys;if(i===void 0)return r;i.failed_marker&&r.push(It("activity",{id:"activity:failed-marker",title:"Deploy failed marker present",href:"/activity"}));for(const s of i.items)s.status==="failed"?r.push(It("activity",{id:`activity:deploy:${s.at}:failed`,title:"Deploy failed",summary:s.detail,href:"/activity",updatedAt:s.at})):s.status==="in-progress"&&r.push(qn("activity",{id:`activity:deploy:${s.at}:in-progress`,title:"Deploy in progress",summary:s.detail,href:"/activity",updatedAt:s.at}));return r}function J4(t,r){for(const i of r){const s=E4(i);if(s==="event")continue;const u=s==="attention"?It:qn;t.push(u("activity",{id:`activity:event:${String(i.seq)}:${i.type}`,title:i.type,summary:I4(i),href:K4(i),updatedAt:i.ts}))}}function K4(t){return`/activity?${new URLSearchParams({mode:"events",type:t.type}).toString()}`}function Q4(t){const r=[];return t===void 0||(t.dashboardError!==void 0&&t.dashboardError.length>0&&r.push(Hn({id:"health:dashboard-health-unavailable",title:"Dashboard health unavailable",summary:t.dashboardError})),t.supervisor!==void 0&&Y4(r,t.supervisor),t.system!==void 0&&(X4(r,t.system),eI(r,t.system)),t.trend!==void 0&&!t.trend.available&&r.push(mr({id:"health:dolt-noms-unavailable",title:"Dolt-noms trend unavailable",summary:t.trend.reason}))),r}function Y4(t,r){if(r.status==="unavailable"){t.push(Hn({id:"health:supervisor-unreachable",title:"Supervisor unreachable",summary:r.error}));return}const i=r.data;i.status!=="ok"&&t.push(Hn({id:"health:supervisor-not-ok",title:`Supervisor ${i.status}`})),i.city===void 0&&t.push(mr({id:"health:supervisor-city-missing",title:"Supervisor city missing",summary:"city was absent from generated supervisor health"})),i.version===void 0&&t.push(mr({id:"health:supervisor-version-missing",title:"Supervisor version missing",summary:"version was absent from generated supervisor health"}))}function X4(t,r){const i=r.admin;i.uptime_sec=b4?t.push(Hn({id:"health:dashboard-process-rss-high",title:"Dashboard RSS high",summary:_a(i.rss_bytes)})):i.rss_bytes>=z4&&t.push(mr({id:"health:dashboard-process-rss-elevated",title:"Dashboard RSS elevated",summary:_a(i.rss_bytes)})),i.heap_used_bytes>=C4?t.push(Hn({id:"health:dashboard-process-heap-high",title:"Dashboard heap high",summary:_a(i.heap_used_bytes)})):i.heap_used_bytes>=T4&&t.push(mr({id:"health:dashboard-process-heap-elevated",title:"Dashboard heap elevated",summary:_a(i.heap_used_bytes)}))}function eI(t,r){const i=Df(r.host.free_mem_bytes,r.host.total_mem_bytes);i!==null&&i<.05?t.push(Hn({id:"health:memory-critical",title:"Host memory critical",summary:`${Math.round(i*100)}% free`})):i!==null&&i<.1&&t.push(mr({id:"health:memory-low",title:"Host memory low",summary:`${Math.round(i*100)}% free`}));const s=Df(r.host.load_avg_1,r.host.cpu_count);s!==null&&s>1.5?t.push(Hn({id:"health:load-high",title:"Host load high",summary:`${r.host.load_avg_1.toFixed(2)} load across ${r.host.cpu_count} CPUs`})):s!==null&&s>1&&t.push(mr({id:"health:load-elevated",title:"Host load elevated",summary:`${r.host.load_avg_1.toFixed(2)} load across ${r.host.cpu_count} CPUs`}))}function _a(t){return t>=1e9?`${(t/1e9).toFixed(1)} GB`:t>=1e6?`${Math.round(t/1e6)} MB`:t>=1e3?`${Math.round(t/1e3)} KB`:`${t} B`}function Df(t,r){return r<=0?null:t/r}function Hn(t){return{domain:"health",severity:"attention",href:"/health",current:!0,actionable:!0,...t}}function It(t,r){return{domain:t,severity:"attention",current:!0,actionable:!0,...r}}function qn(t,r){return{domain:t,severity:"watch",current:!0,actionable:!1,...r}}function Ho(t,r,i){return{domain:t,severity:"unavailable",current:!0,actionable:!1,...r,...i?.provenance===void 0?{}:{provenance:i.provenance},...i?.fetchedAt===void 0?{}:{fetchedAt:i.fetchedAt}}}function mr(t){return{domain:"health",severity:"watch",href:"/health",current:!0,actionable:!1,...t}}const tI=1e3,nI=100,rI="24h",oI=2500;function iI(t,r){const i=Ba(),s=i??"no-city",{decisionLabel:u,operatorWireAlias:p}=t,d=b.useMemo(()=>aI(r),[r]),m=mn(`attention:agents:${s}`,()=>sI(i)),g=mn(`attention:beads:${s}:${u}`,()=>lI(i,u)),y=mn(`attention:mail:${s}:${p}`,()=>dI(i,t)),E=mn(`attention:activity:${s}`,()=>pI(i)),S=mn(`attention:health:${s}`,()=>fI(i));return b.useMemo(()=>P4(mI({activity:E.data,agents:m.data,beads:g.data,health:S.data,mail:y.data,runs:d})),[E.data,m.data,g.data,S.data,y.data,d])}function aI(t){if(t!==void 0)return t.status==="error"?{error:t.error,provenance:"error"}:{summary:t.data,provenance:t.status,fetchedAt:t.fetchedAt}}async function sI(t){if(t===null)return{};try{const r=await ot().listAgents(t),i={items:r.items??[],partial:r.partial===!0};try{const s=await ot().listSessions(t);i.pendingInteractions=await VE(r.items??[],s.items??[])}catch(s){i.pendingError=Jt(s,"agent pending state unavailable")}return i}catch(r){return{error:Jt(r,"agent list unavailable")}}}async function lI(t,r){if(t===null)return{decisionLabel:r};const[i,s,u]=await Promise.allSettled([YE({limit:tI}),uI(t,r),cI(t)]),p={nowMs:Date.now(),decisionLabel:r};return i.status==="fulfilled"?(p.items=i.value.items,p.partial=i.value.partial===!0):p.error=Jt(i.reason,"bead list unavailable"),s.status==="fulfilled"?p.decisions=s.value.items??[]:p.decisionsError=Jt(s.reason,"decision queue unavailable"),u.status==="fulfilled"?p.escalations=u.value.items??[]:p.escalationsError=Jt(u.reason,"escalation queue unavailable"),p}async function uI(t,r){return ot().listBeads(t,{label:r,status:"open"})}async function cI(t){return ot().listBeads(t,{label:B4,status:"open"})}async function dI(t,r){if(t===null)return{};try{const i=await Zu("inbox",r.operatorAlias,r,Uu);return{items:i.items??[],nowMs:Date.now(),partial:i.partial===!0}}catch(i){return{error:Jt(i,"mail list unavailable")}}}async function pI(t){const[r,i]=await Promise.allSettled([Yr.listBuilds(),t===null?Promise.resolve(null):ot().listEvents(t,{limit:nI,since:rI})]),s={};return r.status==="fulfilled"?s.deploys=r.value:s.deploysError=Jt(r.reason,"deploy activity unavailable"),i.status==="fulfilled"?i.value!==null&&(s.events=i.value.items??[],s.eventsPartial=i.value.partial===!0,i.value.partial_errors!==null&&i.value.partial_errors!==void 0&&(s.eventsDegraded=i.value.partial_errors.join("; "))):s.eventsError=Jt(i.reason,"event history unavailable"),s}async function fI(t){if(t===null)return{};const[r,i,s]=await Promise.allSettled([Yr.systemHealth(),UE(oI).cityHealth(t),Yr.doltTrend()]),u={},p=[];return r.status==="fulfilled"?u.system=r.value:p.push(Jt(r.reason,"dashboard health unavailable")),i.status==="fulfilled"?u.supervisor={status:"available",data:i.value}:u.supervisor={status:"unavailable",error:Jt(i.reason,"supervisor health unavailable")},s.status==="fulfilled"?u.trend=s.value:p.push(Jt(s.reason,"dolt-noms trend unavailable")),p.length>0&&(u.dashboardError=p.join("; ")),u}function mI(t){const r={};for(const[i,s]of Object.entries(t))s!==void 0&&(r[i]=s);return r}async function Jr(t){const r={Accept:"application/json","Content-Type":"application/json","X-GC-Request":"dashboard"};try{const i=await fetch("/api/client-errors",{method:"POST",headers:r,credentials:"same-origin",keepalive:!0,body:JSON.stringify(t)});return i.ok?{status:"reported"}:{status:"failed",error:`client error report failed with ${i.status}`}}catch(i){return{status:"failed",error:Wr(i)}}}class _v extends b.Component{state={crashed:!1};static getDerivedStateFromError(){return{crashed:!0}}componentDidCatch(r,i){Jr({component:"ErrorBoundary",operation:"componentDidCatch",message:Wr(r)})}render(){return this.state.crashed?$.jsx("main",{className:"max-w-dashboard mx-auto px-4 sm:px-6 lg:px-8 py-12",children:$.jsxs("section",{className:"space-y-4",role:"alert",children:[$.jsx("h1",{className:"text-display font-semibold text-fg",children:"Dashboard view failed."}),$.jsx("p",{className:"text-body text-fg-muted",children:"The error was reported to the local dashboard log. Refresh to retry this view."})]})}):this.props.children}}function vI({label:t,summary:r}){const i=r.attention+r.watch;if(i===0||r.severity===null)return null;const s=i===1?"item":"items";return $.jsx("span",{"aria-label":`${t}: ${i} ${r.severity} ${s}`,className:`ml-1 align-super text-[0.65rem] leading-none tnum ${hI(r.severity)}`,children:i})}function hI(t){return t==="attention"?"text-accent":"text-warn"}function wv(t,r,i){try{const s=qu(t).getItem(r);return s===null?{status:"missing"}:{status:"found",value:s}}catch(s){return Vu(t,"getItem",r,i,s)}}function xv(t,r,i,s){try{return qu(t).setItem(r,i),{status:"stored"}}catch(u){return Vu(t,"setItem",r,s,u)}}function Ev(t,r,i){try{return qu(t).removeItem(r),{status:"stored"}}catch(s){return Vu(t,"removeItem",r,i,s)}}function qu(t){return t==="localStorage"?window.localStorage:window.sessionStorage}function Vu(t,r,i,s,u){const p=Wr(u);return Jr({component:s,operation:`${t}.${r}`,message:`${i}: ${p}`}),{status:"unavailable",error:p}}const nu="gascity:theme",ru="ThemeContext",Iv=b.createContext(null);function gI(){const t=wv("localStorage",nu,ru);return t.status==="found"&&(t.value==="light"||t.value==="dark")?t.value:"system"}function yI(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function _I(t){const r=document.documentElement;t==="system"?r.removeAttribute("data-theme"):r.setAttribute("data-theme",t)}function wI({children:t}){const[r,i]=b.useState(gI),[s,u]=b.useState(yI);b.useEffect(()=>{const y=window.matchMedia("(prefers-color-scheme: dark)"),E=()=>u(y.matches?"dark":"light");return y.addEventListener("change",E),()=>y.removeEventListener("change",E)},[]);const p=r==="system"?s:r,d=b.useCallback(y=>{i(y),y==="system"?Ev("localStorage",nu,ru):xv("localStorage",nu,y,ru),_I(y)},[]),m=b.useCallback(()=>{d(p==="dark"?"light":"dark")},[p,d]),g=b.useMemo(()=>({pref:r,resolved:p,set:d,toggle:m}),[r,p,d,m]);return $.jsx(Iv.Provider,{value:g,children:t})}function xI(){const t=b.useContext(Iv);if(t===null)throw new Error("useTheme must be used inside ");return t}const Sv={operatorAlias:"operator",operatorWireAlias:"human",decisionLabel:"needs/operator"},kv=b.createContext(Sv);function EI({operator:t,children:r}){return $.jsx(kv.Provider,{value:t,children:r})}function bv(){return b.useContext(kv)}function II(t){return t===void 0?Sv:{operatorAlias:t.operatorAlias,operatorWireAlias:t.operatorWireAlias,decisionLabel:t.decisionLabel}}const SI={ok:"text-ok",warn:"text-warn",stuck:"text-accent",neutral:"text-fg-muted"},kI={ok:"●",warn:"▲",stuck:"■",neutral:"·"};function bI({tone:t,label:r,glyph:i,trailing:s,className:u="",title:p}){return $.jsxs("span",{className:`inline-flex items-baseline gap-1.5 text-body ${SI[t]} ${u}`,title:p,children:[$.jsx("span",{"aria-hidden":!0,className:"text-[0.85em] leading-none translate-y-[1px]",children:i??kI[t]}),$.jsx("span",{children:r}),s&&$.jsx("span",{className:"text-fg-faint text-label uppercase tracking-wider",children:s})]})}function V6(t){switch(t){case"closed":return"neutral";case"in_progress":return"ok";case"blocked":return"stuck";default:return"warn"}}function W6(t){switch(t){case"active":case"running":return"ok";case"rate-limited":case"rate_limited":case"waiting":return"warn";case"failed":case"closed":case"errored":case"stuck":return"stuck";default:return"neutral"}}const zv=b.createContext(!1);function zI({readOnly:t,children:r}){return $.jsx(zv.Provider,{value:t,children:r})}function CI(){return b.useContext(zv)}function TI(t,r){return t?t.readOnly:r!==null}const Cv="Read-only mode: mutations are disabled";function H6(){return $.jsx(bI,{tone:"warn",label:"Read-only",title:Cv})}const BI="mayor";function RI(t){const{operator:r,sessionAliases:i,mailFromOrTo:s}=t,u=new Map;for(const A of i){const D=A.toLowerCase();u.has(D)||u.set(D,A)}for(const A of s){const D=A.toLowerCase();u.has(D)||u.set(D,A)}const p=r.toLowerCase(),d=new Set(s.map(A=>A.toLowerCase())),m=[r],g=[],y=[],E=[];for(const[A,D]of u)if(A!==p){if(A===BI){g.push(D);continue}d.has(A)?y.push(D):E.push(D)}const S=(A,D)=>A.toLowerCase().localeCompare(D.toLowerCase());y.sort(S),E.sort(S);const T=[{tier:"you",aliases:m}];return g.length>0&&T.push({tier:"mayor",aliases:g}),y.length>0&&T.push({tier:"active",aliases:y}),E.length>0&&T.push({tier:"other",aliases:E}),T}function PI(t,r){return t===r?"user":t}function G6(t){switch(t){case"you":return"You";case"mayor":return"Mayor";case"active":return"Active";case"other":return"Other"}}async function NI(){return ot().listSessions(xn("list supervisor sessions"))}async function J6(t){const r=await ot().sessionTranscript(xn("fetch supervisor session transcript"),t);return OI(r)}function K6(t){return(t.items??[]).map(AI)}function AI(t){const r={id:t.id,template:t.template,session_name:t.session_name,title:t.title,state:t.state,created_at:t.created_at,attached:t.attached,running:t.running,provider:t.provider};return t.alias!==void 0&&(r.alias=t.alias),t.reason!==void 0&&(r.reason=t.reason),t.display_name!==void 0&&(r.display_name=t.display_name),t.last_active!==void 0&&(r.last_active=t.last_active),t.rig!==void 0&&(r.rig=t.rig),t.pool!==void 0&&(r.pool=t.pool),t.agent_kind!==void 0&&(r.agent_kind=t.agent_kind),t.model!==void 0&&(r.model=t.model),t.context_pct!==void 0&&(r.context_pct=t.context_pct),t.context_window!==void 0&&(r.context_window=t.context_window),t.activity!==void 0&&(r.activity=t.activity),r}function OI(t,r=new Date().toISOString()){const i=t.turns??[];return{...t,turns:i,total_chars:i.reduce((s,u)=>s+u.text.length,0),captured_at:r,truncated:!1}}const ou="gascity.dashboard.viewingAs",Kr="ViewingAsContext",Mf=/^[a-z][a-z0-9_./-]{1,63}$/i,Ff=[3e4,9e4,27e4];function jI(t){if(!Number.isInteger(t)||t<0||t>=Ff.length)return null;const r=Ff[t];return r===void 0?null:r}const Tv=b.createContext(null);function Uf(t){const r=wv("sessionStorage",ou,Kr);if(r.status==="found"){const i=r.value;if(i.length>0&&i.length<=64)return i}return t}function Zl(t,r){t===r?Ev("sessionStorage",ou,Kr):xv("sessionStorage",ou,t,Kr)}function $I({children:t}){const r=bv(),{operatorAlias:i}=r,[s,u]=b.useState(()=>Uf(i)),p=b.useRef(i),[d,m]=b.useState([]),[g,y]=b.useState([]),[E,S]=b.useState(!1),[T,A]=b.useState(!1),D=b.useRef(!1),W=b.useRef(!0),O=b.useRef(null),H=b.useCallback(pe=>{u(pe),Zl(pe,i)},[i]),oe=b.useCallback(()=>{u(i),Zl(i,i)},[i]),Q=b.useCallback(async()=>{try{const pe=await NI();if(!W.current)return!0;const Re=new Set,ye=[];for(const Ze of pe.items??[]){if(typeof Ze.alias!="string"||!Mf.test(Ze.alias))continue;const Ke=Ze.alias.toLowerCase();Re.has(Ke)||(Re.add(Ke),ye.push(Ze.alias))}return m(ye),A(!1),!0}catch(pe){return Jr({component:Kr,operation:"loadAliases.sessions",message:Wr(pe)}),!1}},[]),G=b.useCallback(pe=>{if(!W.current)return;const Re=jI(pe);Re!==null&&(O.current=setTimeout(()=>{O.current=null,W.current&&Q().then(ye=>{W.current&&(ye||G(pe+1))}).catch(ye=>{Jr({component:Kr,operation:"loadAliases.sessionsRetry",message:Wr(ye)})})},Re))},[Q]),ee=b.useCallback(()=>{if(D.current)return;D.current=!0,S(!0);let pe=2;const Re=()=>{pe-=1,pe===0&&W.current&&S(!1)};Q().then(ye=>{W.current&&(ye||(A(!0),G(0)))}).finally(Re),Zu("all",i,r).then(ye=>{if(!W.current)return;const Ze=new Set,Ke=[];for(const et of ye.items)for(const Qe of[et.from,et.to]){if(typeof Qe!="string"||Qe.length===0||!Mf.test(Qe))continue;const kt=Qe.toLowerCase();Ze.has(kt)||(Ze.add(kt),Ke.push(Qe))}y(Ke)}).catch(ye=>{Jr({component:Kr,operation:"loadAliases.mail",message:Wr(ye)})}).finally(Re)},[Q,G,i,r]);b.useEffect(()=>(W.current=!0,()=>{W.current=!1,O.current!==null&&(clearTimeout(O.current),O.current=null)}),[]),b.useEffect(()=>{const pe=p.current;p.current=i,pe!==i&&s===pe&&u(Uf(i))},[i,s]);const ue=b.useMemo(()=>RI({operator:i,sessionAliases:d.includes(s)?d:[...d,s],mailFromOrTo:g}),[d,g,s,i]),de=b.useMemo(()=>({viewingAs:{alias:s,isOperator:s===i},setAlias:H,resetToOperator:oe,aliasBuckets:ue,aliasesLoading:E,sessionsUnavailable:T,loadAliases:ee}),[s,i,H,oe,ue,E,T,ee]);return b.useEffect(()=>{const pe=()=>{document.hidden&&s!==i&&(u(i),Zl(i,i))};return document.addEventListener("visibilitychange",pe),()=>document.removeEventListener("visibilitychange",pe)},[s,i]),$.jsx(Tv.Provider,{value:de,children:t})}function LI(){const t=b.useContext(Tv);if(t===null)throw new Error("useViewingAs must be inside ");return t}const DI={id:"activity",kind:"core",path:"/activity",nav:{label:"Activity",order:55},element:b.lazy(()=>wn(()=>import("./Activity-C0ndMSgp.js"),__vite__mapDeps([0,1,2,3,4])).then(t=>({default:t.ActivityPage})))},MI={id:"health",kind:"core",path:"/health",nav:{label:"Health",order:60},element:b.lazy(()=>wn(()=>import("./Health-DWOkvU0J.js"),__vite__mapDeps([5,1,2,4,6,3])).then(t=>({default:t.HealthPage})))},Bv=[DI,MI],FI={views:"views"};function UI(t,r){console.warn(`[${t}] ${r}`)}function Rv(t,r){const i=new Set(r??[]);return t.filter(s=>s.kind==="core"||i.has(s.id))}const ZI={};function qI(t,r){const i=[];if(r!==null){const d=ZI[r];if(d!==void 0){if(t.some(g=>g.id===d.target))return{view:null,redirectTo:d.redirectTo,source:"env",warnings:i};i.push(`DEFAULT_VIEW="${r}" alias targets the "${d.target}" view, which is not enabled in this deployment (known enabled ids: ${t.map(g=>g.id).join(", ")||"(none)"}); falling through to descriptor / ambient-home`)}else{const m=t.find(g=>g.id===r);if(m!==void 0)return{view:m,source:"env",warnings:i};i.push(`DEFAULT_VIEW="${r}" does not match any enabled view (known enabled ids: ${t.map(g=>g.id).join(", ")||"(none)"}); falling through to descriptor / ambient-home`)}}const s=t.filter(d=>d.defaultRoute===!0),[u,...p]=s;if(u!==void 0&&p.length===0)return{view:u,source:"descriptor",warnings:i};if(u!==void 0){const m=[...s].sort(WI)[0]??u;return i.push(`multiple views declare defaultRoute: true (${s.map(g=>g.id).join(", ")}); picking "${m.id}" by lowest nav.order`),{view:m,source:"descriptor",warnings:i}}return{view:null,source:"fallback",warnings:i}}function VI(t,r){const i=qI(t,r);for(const s of i.warnings)UI(FI.views,s);return i}function WI(t,r){const i=t.nav?.order??Number.POSITIVE_INFINITY,s=r.nav?.order??Number.POSITIVE_INFINITY;return i!==s?i-s:t.id.localeCompare(r.id)}const HI=[{to:"/",label:"Home",end:!0,order:10},{to:"/agents",label:"Agents",order:20},{to:"/beads",label:"Beads",order:30},{to:"/runs",label:"Runs",order:40},{to:"/mail",label:"Mail",order:50}],GI={"/agents":"agents","/beads":"beads","/runs":"runs","/mail":"mail","/activity":"activity","/health":"health"};function JI(){const{resolved:t,toggle:r}=xI(),{viewingAs:i}=LI(),{operatorAlias:s}=bv(),u=CI(),p=qy(),{data:d}=mn("config",()=>Yr.config()),{data:m}=mn("cities",()=>ot().listCities()),g=Ba(),y=m?.items??[],E=g??d?.cityName??"",S=E===""||y.some(H=>H.name===E),T=y.length>1||!S,A=H=>{H!==g&&window.location.assign(`/city/${encodeURIComponent(H)}/`)},D=b.useMemo(()=>{const oe=Rv(Bv,d?.enabledModules??null).flatMap(Q=>Q.nav===null?[]:[{to:Q.path,label:Q.nav.label,end:Q.path==="/",order:Q.nav.order}]);return[...HI,...oe].sort((Q,G)=>Q.order-G.order)},[d?.enabledModules]),{pathname:W}=_n(),O=!i.isOperator&&W.startsWith("/mail");return $.jsx("header",{className:"border-b border-rule",children:$.jsxs("div",{className:"max-w-dashboard mx-auto px-4 sm:px-6 lg:px-8 py-5 flex items-baseline gap-x-6 lg:gap-x-8 gap-y-2 flex-wrap",children:[$.jsxs("div",{className:"flex items-baseline gap-3 min-w-0",children:[$.jsx("span",{className:"text-title font-semibold tracking-tight text-fg",children:"gas city"}),$.jsx("span",{className:"text-fg-muted","aria-hidden":"true",children:"·"}),T?$.jsx("label",{className:"sr-only",htmlFor:"city-switcher",children:"Switch city"}):null,T?$.jsxs("select",{id:"city-switcher",value:E,onChange:H=>A(H.target.value),className:"text-label uppercase tracking-wider text-fg-muted bg-transparent border-0 focus-mark cursor-pointer hover:text-fg transition-colors duration-150 ease-out-quart",children:[!S&&E!==""?$.jsxs("option",{value:E,disabled:!0,children:[E," (unknown)"]}):null,y.map(H=>$.jsxs("option",{value:H.name,children:[H.name,H.running?"":" (stopped)"]},H.name))]}):$.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:E||"city"}),O&&$.jsxs("span",{className:"text-label uppercase tracking-wider text-accent ml-3",children:["· reading as ",PI(i.alias,s)]}),u&&$.jsx("span",{title:Cv,className:"text-label uppercase tracking-wider text-warn ml-3",children:"· read-only"})]}),$.jsx("nav",{className:"flex-1",children:$.jsx("ul",{className:"flex items-baseline gap-x-5 lg:gap-x-7 gap-y-1 flex-wrap",children:D.map(H=>{const oe=GI[H.to];return $.jsx("li",{children:$.jsxs(W0,{to:H.to,end:H.end??!1,className:({isActive:Q})=>["text-title transition-colors duration-150 ease-out-quart focus-mark",Q?"text-fg font-semibold":"text-fg-muted font-medium hover:text-fg"].join(" "),children:[H.label,oe!==void 0&&$.jsx(vI,{label:H.label,summary:p.byDomain[oe]})]})},H.to)})})}),$.jsx("button",{type:"button",onClick:r,"aria-label":`Switch to ${t==="dark"?"light":"dark"} theme`,className:"text-label uppercase tracking-wider text-fg-muted hover:text-fg transition-colors duration-150 ease-out-quart focus-mark",children:t==="dark"?"Light":"Dark"})]})})}function KI({children:t}){return $.jsxs("div",{className:"min-h-screen bg-surface text-fg antialiased",children:[$.jsx(JI,{}),$.jsx("main",{className:"max-w-dashboard mx-auto px-4 sm:px-6 lg:px-8 py-12",children:t})]})}const Pv=b.createContext(null);function QI({children:t,intervalMs:r=1e3}){const[i,s]=b.useState(()=>Date.now());return b.useEffect(()=>{const u=window.setInterval(()=>{s(Date.now())},r);return()=>{window.clearInterval(u)}},[r]),$.jsx(Pv.Provider,{value:i,children:t})}function Q6(){const t=b.useContext(Pv);if(t===null)throw new Error("useNow must be called inside a NowProvider.");return t}const YI=2e3,XI=2500;function e6(t,r,i={}){const[s,u]=b.useState("connecting"),p=b.useRef(r);p.current=r;const d=b.useRef(i.matches);d.current=i.matches;const m=b.useRef(i.coalesceMs);m.current=i.coalesceMs;const g=t.join(","),y=b.useRef(0),E=b.useRef(null);return b.useEffect(()=>{if(t.length===0){u("closed");return}let S=null,T=!1,A=null,D=null,W=1e3,O=!1;const H=()=>{D!==null&&(clearTimeout(D),D=null)},oe=ue=>{O||(O=!0,t6(ue))},Q=()=>{y.current=Date.now(),p.current()},G=()=>{const ue=m.current??XI,de=Date.now()-y.current;de>=ue?(E.current&&(clearTimeout(E.current),E.current=null),Q()):E.current===null&&(E.current=setTimeout(()=>{E.current=null,T||Q()},ue-de))},ee=()=>{const ue=globalThis.EventSource;if(typeof ue!="function"){u("closed");return}const de=Ba();if(de===null){u("closed");return}const pe=new ue(ot().cityEventStreamUrl(de));S=pe,u("connecting"),D=setTimeout(()=>{T||S!==pe||pe.readyState===ue.CLOSED||u("open")},YI),S.onopen=()=>{T||(H(),u("open"),W=1e3)};const Re=ye=>{if(T)return;let Ze=null;try{Ze=JSON.parse(ye.data)}catch{u("degraded"),oe("invalid JSON");return}if(!n6(Ze)){u("degraded"),oe("missing string event type");return}const Ke=Ze.type;if(typeof Ke!="string"){u("degraded"),oe("missing string event type");return}u("open");for(const et of t)if(Ke.startsWith(et)){const Qe=Ze;(d.current?.(Qe)??!0)&&G();break}};S.onmessage=Re,S.addEventListener("event",Re),S.onerror=()=>{T||(H(),u("closed"),S?.close(),S=null,A=setTimeout(()=>{W=Math.min(W*2,3e4),ee()},W))}};return ee(),()=>{T=!0,A&&clearTimeout(A),H(),E.current&&(clearTimeout(E.current),E.current=null),S?.close()}},[g]),s}function t6(t){Jr({component:"gc-events",operation:"parse event",message:`Malformed gc event payload: ${t}.`})}function n6(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)}const r6=60*1e3;async function Da(){const t=new Date().toISOString();try{const r=await Yr.runSummary();return{source:"runs",status:"fresh",fetchedAt:t,staleAt:new Date(Date.parse(t)+r6).toISOString(),error:{kind:"none"},data:r}}catch(r){return{source:"runs",status:"error",error:s6(r,"formula runs unavailable")}}}function o6(){return Da()}function Y6(){return Da()}function i6(){return Da()}function a6(){return Da()}function s6(t,r){return t instanceof Error&&t.message.trim().length>0?t.message:r}const Zf=1e4,l6=[2e3,5e3,1e4];function u6(){const t=Ba(),r=b.useRef(null),i=b.useRef(!1),s=b.useCallback(async()=>{const ee=await o6().catch(de=>({source:"runs",status:"error",error:de instanceof Error?de.message:"formula runs unavailable"}));if(ee.status!=="error")return i.current=!1,ee;const ue=r.current;return ue===null?ee:(i.current=!0,{...ue,status:"stale"})},[]),u=b.useCallback(async()=>{const ee=await i6().catch(de=>({source:"runs",status:"error",error:de instanceof Error?de.message:"formula runs unavailable"}));if(ee.status!=="error")return ee;const ue=r.current;return ue===null?ee:(i.current=!0,{...ue,status:"stale"})},[]),{data:p,loading:d,error:m,refresh:g,cheapRefresh:y}=mn(`runs:summary:${t??"no-city"}`,a6,{refreshFetcher:s,sseRefreshFetcher:u});p!==void 0&&p.status!=="error"&&(r.current=p);const E=p??null,S=b.useRef(null);S.current=E?.status??null;const T=b.useRef(d);T.current=d;const A=b.useRef(0),D=b.useRef(null);b.useEffect(()=>{if(E===null||E.status==="error")return;const ee=t??"no-city";D.current!==ee&&(D.current=ee,g().catch(()=>{D.current=null}))},[t,g,E]);const W=b.useRef(0);b.useEffect(()=>{if(E===null)return;if(!(E.status==="error"?!0:i.current||E.data.lanesPartial===!0&&E.data.lanes.length===0&&E.data.blockedLanes.length===0)){W.current=0;return}const ue=l6[W.current];if(ue===void 0)return;W.current+=1;const de=setTimeout(()=>{g()},ue);return()=>clearTimeout(de)},[E,g]);const O=b.useRef(!1),H=b.useRef(null),oe=b.useCallback(()=>{H.current!==null&&(clearTimeout(H.current),H.current=null),A.current=Date.now(),y().catch(()=>{A.current=0})},[y]),Q=b.useCallback(()=>{if(S.current===null||S.current==="fixture")return;if(T.current){O.current=!0;return}Date.now()-A.current{if(d||!O.current)return;O.current=!1;const ee=Math.max(0,Zf-(Date.now()-A.current));return H.current=setTimeout(oe,ee),()=>{H.current!==null&&(clearTimeout(H.current),H.current=null)}},[d,oe]);const G=e6([ly.bead],Q);return{source:p,loading:d,error:m,refresh:g,sseState:G}}const Nv=b.createContext(null);function c6({children:t}){const r=u6();return $.jsx(Nv.Provider,{value:r,children:t})}function d6(){const t=b.useContext(Nv);if(t===null)throw new Error("useRunSummary must be used within a RunSummaryProvider");return t}const p6=b.lazy(()=>wn(()=>import("./Agents-sZ3Kn-9C.js"),__vite__mapDeps([7,8,1,9,10,2,11,3,12,6,13,14])).then(t=>({default:t.AgentsPage}))),f6=b.lazy(()=>wn(()=>import("./AgentDetail-4AW6d3TF.js"),__vite__mapDeps([15,16,6,17,12,3,10,2,8,14])).then(t=>({default:t.AgentDetailPage}))),m6=b.lazy(()=>wn(()=>import("./AmbientHome-QKhI8-ES.js"),__vite__mapDeps([18,2])).then(t=>({default:t.AmbientHomePage}))),v6=b.lazy(()=>wn(()=>import("./Beads-CRhPo2Gt.js"),__vite__mapDeps([19,1,16,6,17,12,3,10,20,9,2,14])).then(t=>({default:t.BeadsPage}))),h6=b.lazy(()=>wn(()=>import("./Mail-CfeMOQZF.js"),__vite__mapDeps([21,9,1,20,13,10,2,17,3])).then(t=>({default:t.MailPage}))),g6=b.lazy(()=>wn(()=>import("./FormulaRunDetail-BIoITriX.js"),__vite__mapDeps([22,2,16,6,17,12,3,10,23])).then(t=>({default:t.FormulaRunDetailPage}))),y6=b.lazy(()=>wn(()=>import("./Runs-DlWanzbB.js"),__vite__mapDeps([24,1,2,11,3,23])).then(t=>({default:t.RunsPage})));function _6(){const{data:t,error:r}=mn("config",()=>Yr.config()),i=t?.enabledModules??null,s=t?.defaultView??null,u=TI(t,r),p=II(t),d=b.useMemo(()=>Rv(Bv,i),[i]),m=b.useMemo(()=>VI(d,s),[d,s]),g=m.view?.element??null,y=m.redirectTo??null;return $.jsx(EI,{operator:p,children:$.jsx($I,{children:$.jsx(QI,{children:$.jsx(zI,{readOnly:u,children:$.jsx(c6,{children:$.jsx(w6,{operator:p,children:$.jsxs(KI,{children:[r!==null&&$.jsx(E6,{message:r}),$.jsx(x6,{defaultRedirectTo:y,DefaultViewElement:g,enabledViews:d})]})})})})})})})}function w6({operator:t,children:r}){const{source:i}=d6(),s=iI(t,i);return $.jsx(Zy,{contributors:s,children:r})}function x6({defaultRedirectTo:t,DefaultViewElement:r,enabledViews:i}){const{pathname:s}=_n();return $.jsx(_v,{children:$.jsx(b.Suspense,{fallback:null,children:$.jsxs(N0,{children:[$.jsx(on,{path:"/",element:t!==null?$.jsx(R0,{to:t,replace:!0}):r!==null?$.jsx(r,{}):$.jsx(m6,{})}),$.jsx(on,{path:"/agents",element:$.jsx(p6,{})}),$.jsx(on,{path:"/agents/:slug",element:$.jsx(f6,{})}),$.jsx(on,{path:"/beads",element:$.jsx(v6,{})}),$.jsx(on,{path:"/runs",element:$.jsx(y6,{})}),$.jsx(on,{path:"/runs/:runId",element:$.jsx(g6,{})}),$.jsx(on,{path:"/mail",element:$.jsx(h6,{})}),i.map(u=>{const p=u.element;return $.jsx(on,{path:u.path,element:$.jsx(p,{})},u.id)}),$.jsx(on,{path:"*",element:$.jsx(I6,{})})]})})},s)}function E6({message:t}){return $.jsxs("section",{role:"alert",className:"mb-8 border border-warn/40 rounded-sm px-4 py-3 text-body text-fg-muted",children:[$.jsx("span",{className:"text-warn",children:"config unavailable:"})," ",t," · some controls may be disabled until it loads."]})}function I6(){return $.jsxs("section",{"aria-labelledby":"not-found-title",className:"space-y-3",children:[$.jsx("h1",{id:"not-found-title",className:"text-5xl font-semibold tracking-tight text-fg",children:"Page not found"}),$.jsx("p",{className:"text-title text-fg-muted",children:"No dashboard route matches this path."})]})}const S6={default:"border border-rule text-fg-muted hover:text-fg hover:bg-surface-tint",accent:"border border-accent text-accent hover:bg-accent hover:text-surface",quiet:"border border-transparent text-fg-muted hover:text-fg"},k6={sm:"px-2.5 py-1 text-label uppercase tracking-wider",md:"px-3.5 py-1.5 text-body"};function b6({tone:t="default",size:r="sm",className:i="",children:s,...u}){return $.jsx("button",{...u,className:`inline-flex items-center gap-1.5 rounded-sm transition-colors duration-150 ease-out-quart focus-mark disabled:opacity-40 disabled:cursor-not-allowed ${S6[t]} ${k6[r]} ${i}`,children:s})}const z6="https://docs.gascity.com/getting-started/quickstart",C6=/^\/city\/([^/]+)(?:\/|$)/;function T6(t){const r=C6.exec(t);if(r===null)return null;const i=r[1];if(i===void 0)return null;let s;try{s=decodeURIComponent(i)}catch{return null}return om.test(s)?{cityName:s,basename:`/city/${i}`}:null}function B6(){const t=b.useMemo(()=>T6(window.location.pathname),[]),[r,i]=b.useState({phase:"loading"}),[s,u]=b.useState(0),p=b.useCallback(()=>{i({phase:"loading"}),u(d=>d+1)},[]);return b.useEffect(()=>{let d=!1;return i({phase:"loading"}),ot().listCities().then(m=>{if(d)return;const g=m.items??[];if(t!==null){const E=g.some(S=>S.name===t.cityName);i(E?{phase:"mount"}:{phase:"unknown-city",cities:g});return}const y=g[0];if(y===void 0){i({phase:"empty"});return}window.location.replace(`/city/${encodeURIComponent(y.name)}/`)}).catch(m=>{if(!d){if(t!==null){i({phase:"mount"});return}i({phase:"error",message:m instanceof Error?m.message:"failed to load cities"})}}),()=>{d=!0}},[t,s]),t!==null&&r.phase==="mount"?(vy(t.cityName),$.jsx(U0,{basename:t.basename,future:{v7_relativeSplatPath:!0,v7_startTransition:!0},children:$.jsx(_6,{})})):r.phase==="unknown-city"&&t!==null?$.jsx(R6,{cityName:t.cityName,cities:r.cities}):r.phase==="empty"?$.jsx(P6,{}):r.phase==="error"?$.jsx(N6,{message:r.message,onRetry:p}):$.jsx(Ma,{children:$.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Resolving city…"})})}function Ma({children:t}){return $.jsx("div",{className:"min-h-screen bg-surface text-fg antialiased flex items-center justify-center px-6",children:$.jsx("div",{className:"max-w-prose w-full space-y-4",children:t})})}function R6({cityName:t,cities:r}){return $.jsx(Ma,{children:$.jsxs("section",{role:"alert",className:"space-y-4",children:[$.jsxs("h1",{className:"text-display font-semibold text-fg",children:["City “",t,"” is not registered on this supervisor."]}),r.length>0?$.jsxs("div",{className:"space-y-2",children:[$.jsx("p",{className:"text-body text-fg-muted",children:"Available cities:"}),$.jsx("ul",{className:"space-y-1",children:r.map(i=>$.jsxs("li",{children:[$.jsx("a",{href:`/city/${encodeURIComponent(i.name)}/`,className:"text-body text-accent hover:underline focus-mark",children:i.name}),i.running?null:$.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted ml-2",children:"· stopped"})]},i.name))})]}):$.jsx(Av,{})]})})}function P6(){return $.jsx(Ma,{children:$.jsxs("section",{className:"space-y-4",children:[$.jsx("h1",{className:"text-display font-semibold text-fg",children:"No cities are registered on this supervisor."}),$.jsx(Av,{})]})})}function Av(){return $.jsxs("div",{className:"space-y-3",children:[$.jsx("p",{className:"text-body text-fg-muted",children:"Create one from a terminal:"}),$.jsx("pre",{className:"text-body bg-surface-tint rounded-sm px-3 py-2 overflow-x-auto",children:$.jsx("code",{children:"gc init ~/my-city"})}),$.jsxs("p",{className:"text-body text-fg-muted",children:[$.jsx("code",{children:"gc init"})," bootstraps the city directory, registers it with the supervisor, and starts the orchestrator. Then refresh this page. See the"," ",$.jsx("a",{href:z6,target:"_blank",rel:"noreferrer",className:"text-accent hover:underline focus-mark",children:"getting-started guide"})," ","for the full walkthrough."]})]})}function N6({message:t,onRetry:r}){return $.jsx(Ma,{children:$.jsxs("section",{role:"alert",className:"space-y-4",children:[$.jsx("h1",{className:"text-display font-semibold text-fg",children:"Could not load cities."}),$.jsx("p",{className:"text-body text-fg-muted",children:t}),$.jsx(b6,{onClick:r,children:"Retry"})]})})}const Ov=document.getElementById("root");if(!Ov)throw new Error("missing #root");Ug.createRoot(Ov).render($.jsx(Vf.StrictMode,{children:$.jsx(wI,{children:$.jsx(_v,{children:$.jsx(B6,{})})})}));export{Fl as $,Zu as A,b6 as B,Ay as C,wv as D,xv as E,Y6 as F,ly as G,Ba as H,ot as I,xn as J,O6 as K,V0 as L,PI as M,G6 as N,Uu as O,n4 as P,U6 as Q,H6 as R,bI as S,py as T,dy as U,F6 as V,M6 as W,Yr as X,im as Y,Vy as Z,Ny as _,qy as a,D6 as a0,Wn as a1,K6 as a2,V6 as a3,J6 as a4,OI as a5,Lf as a6,oy as a7,d6 as a8,E4 as a9,I4 as aa,UE as ab,mn as b,YE as c,VE as d,X0 as e,e6 as f,CI as g,j6 as h,Cv as i,$ as j,$6 as k,NI as l,_4 as m,q6 as n,Z6 as o,Wr as p,A6 as q,b as r,W6 as s,uu as t,Q6 as u,LI as v,bv as w,Jr as x,L6 as y,Jt as z}; +Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.`)}for(const d of t.seen.entries()){const m=d[1];if(r===d[0]){p(d);continue}if(t.external){const y=t.external.registry.get(d[0])?.id;if(r!==d[0]&&y){p(d);continue}}if(t.metadataRegistry.get(d[0])?.id){p(d);continue}if(m.cycle){p(d);continue}if(m.count>1&&t.reused==="ref"){p(d);continue}}}function Fm(t,r){const i=t.seen.get(r);if(!i)throw new Error("Unprocessed schema. This is a bug in Zod.");const s=m=>{const g=t.seen.get(m);if(g.ref===null)return;const y=g.def??g.schema,E={...y},S=g.ref;if(g.ref=null,S){s(S);const A=t.seen.get(S),D=A.schema;if(D.$ref&&(t.target==="draft-07"||t.target==="draft-04"||t.target==="openapi-3.0")?(y.allOf=y.allOf??[],y.allOf.push(D)):Object.assign(y,D),Object.assign(y,E),m._zod.parent===S)for(const O in y)O==="$ref"||O==="allOf"||O in E||delete y[O];if(D.$ref&&A.def)for(const O in y)O==="$ref"||O==="allOf"||O in A.def&&JSON.stringify(y[O])===JSON.stringify(A.def[O])&&delete y[O]}const T=m._zod.parent;if(T&&T!==S){s(T);const A=t.seen.get(T);if(A?.schema.$ref&&(y.$ref=A.schema.$ref,A.def))for(const D in y)D==="$ref"||D==="allOf"||D in A.def&&JSON.stringify(y[D])===JSON.stringify(A.def[D])&&delete y[D]}t.override({zodSchema:m,jsonSchema:y,path:g.path??[]})};for(const m of[...t.seen.entries()].reverse())s(m[0]);const u={};if(t.target==="draft-2020-12"?u.$schema="https://json-schema.org/draft/2020-12/schema":t.target==="draft-07"?u.$schema="http://json-schema.org/draft-07/schema#":t.target==="draft-04"?u.$schema="http://json-schema.org/draft-04/schema#":t.target,t.external?.uri){const m=t.external.registry.get(r)?.id;if(!m)throw new Error("Schema is missing an `id` property");u.$id=t.external.uri(m)}Object.assign(u,i.def??i.schema);const p=t.metadataRegistry.get(r)?.id;p!==void 0&&u.id===p&&delete u.id;const d=t.external?.defs??{};for(const m of t.seen.entries()){const g=m[1];g.def&&g.defId&&(g.def.id===g.defId&&delete g.def.id,d[g.defId]=g.def)}t.external||Object.keys(d).length>0&&(t.target==="draft-2020-12"?u.$defs=d:u.definitions=d);try{const m=JSON.parse(JSON.stringify(u));return Object.defineProperty(m,"~standard",{value:{...r["~standard"],jsonSchema:{input:ba(r,"input",t.processors),output:ba(r,"output",t.processors)}},enumerable:!1,writable:!1}),m}catch{throw new Error("Error converting schema to JSON.")}}function ft(t,r){const i=r??{seen:new Set};if(i.seen.has(t))return!1;i.seen.add(t);const s=t._zod.def;if(s.type==="transform")return!0;if(s.type==="array")return ft(s.element,i);if(s.type==="set")return ft(s.valueType,i);if(s.type==="lazy")return ft(s.getter(),i);if(s.type==="promise"||s.type==="optional"||s.type==="nonoptional"||s.type==="nullable"||s.type==="readonly"||s.type==="default"||s.type==="prefault")return ft(s.innerType,i);if(s.type==="intersection")return ft(s.left,i)||ft(s.right,i);if(s.type==="record"||s.type==="map")return ft(s.keyType,i)||ft(s.valueType,i);if(s.type==="pipe")return t._zod.traits.has("$ZodCodec")?!0:ft(s.in,i)||ft(s.out,i);if(s.type==="object"){for(const u in s.shape)if(ft(s.shape[u],i))return!0;return!1}if(s.type==="union"){for(const u of s.options)if(ft(u,i))return!0;return!1}if(s.type==="tuple"){for(const u of s.items)if(ft(u,i))return!0;return!!(s.rest&&ft(s.rest,i))}return!1}const $_=(t,r={})=>i=>{const s=Dm({...i,processors:r});return Je(t,s),Mm(s,t),Fm(s,t)},ba=(t,r,i={})=>s=>{const{libraryOptions:u,target:p}=s??{},d=Dm({...u??{},target:p,io:r,processors:i});return Je(t,d),Mm(d,t),Fm(d,t)},L_={guid:"uuid",url:"uri",datetime:"date-time",json_string:"json-string",regex:""},D_=(t,r,i,s)=>{const u=i;u.type="string";const{minimum:p,maximum:d,format:m,patterns:g,contentEncoding:y}=t._zod.bag;if(typeof p=="number"&&(u.minLength=p),typeof d=="number"&&(u.maxLength=d),m&&(u.format=L_[m]??m,u.format===""&&delete u.format,m==="time"&&delete u.format),y&&(u.contentEncoding=y),g&&g.size>0){const E=[...g];E.length===1?u.pattern=E[0].source:E.length>1&&(u.allOf=[...E.map(S=>({...r.target==="draft-07"||r.target==="draft-04"||r.target==="openapi-3.0"?{type:"string"}:{},pattern:S.source}))])}},M_=(t,r,i,s)=>{const u=i,{minimum:p,maximum:d,format:m,multipleOf:g,exclusiveMaximum:y,exclusiveMinimum:E}=t._zod.bag;typeof m=="string"&&m.includes("int")?u.type="integer":u.type="number";const S=typeof E=="number"&&E>=(p??Number.NEGATIVE_INFINITY),T=typeof y=="number"&&y<=(d??Number.POSITIVE_INFINITY),A=r.target==="draft-04"||r.target==="openapi-3.0";S?A?(u.minimum=E,u.exclusiveMinimum=!0):u.exclusiveMinimum=E:typeof p=="number"&&(u.minimum=p),T?A?(u.maximum=y,u.exclusiveMaximum=!0):u.exclusiveMaximum=y:typeof d=="number"&&(u.maximum=d),typeof g=="number"&&(u.multipleOf=g)},F_=(t,r,i,s)=>{i.type="boolean"},U_=(t,r,i,s)=>{if(r.unrepresentable==="throw")throw new Error("BigInt cannot be represented in JSON Schema")},Z_=(t,r,i,s)=>{i.not={}},q_=(t,r,i,s)=>{},V_=(t,r,i,s)=>{const u=t._zod.def,p=gm(u.entries);p.every(d=>typeof d=="number")&&(i.type="number"),p.every(d=>typeof d=="string")&&(i.type="string"),i.enum=p},W_=(t,r,i,s)=>{const u=t._zod.def,p=[];for(const d of u.values)if(d===void 0){if(r.unrepresentable==="throw")throw new Error("Literal `undefined` cannot be represented in JSON Schema")}else if(typeof d=="bigint"){if(r.unrepresentable==="throw")throw new Error("BigInt literals cannot be represented in JSON Schema");p.push(Number(d))}else p.push(d);if(p.length!==0)if(p.length===1){const d=p[0];i.type=d===null?"null":typeof d,r.target==="draft-04"||r.target==="openapi-3.0"?i.enum=[d]:i.const=d}else p.every(d=>typeof d=="number")&&(i.type="number"),p.every(d=>typeof d=="string")&&(i.type="string"),p.every(d=>typeof d=="boolean")&&(i.type="boolean"),p.every(d=>d===null)&&(i.type="null"),i.enum=p},H_=(t,r,i,s)=>{if(r.unrepresentable==="throw")throw new Error("Custom types cannot be represented in JSON Schema")},G_=(t,r,i,s)=>{if(r.unrepresentable==="throw")throw new Error("Transforms cannot be represented in JSON Schema")},J_=(t,r,i,s)=>{const u=i,p=t._zod.def,{minimum:d,maximum:m}=t._zod.bag;typeof d=="number"&&(u.minItems=d),typeof m=="number"&&(u.maxItems=m),u.type="array",u.items=Je(p.element,r,{...s,path:[...s.path,"items"]})},K_=(t,r,i,s)=>{const u=i,p=t._zod.def;u.type="object",u.properties={};const d=p.shape;for(const y in d)u.properties[y]=Je(d[y],r,{...s,path:[...s.path,"properties",y]});const m=new Set(Object.keys(d)),g=new Set([...m].filter(y=>{const E=p.shape[y]._zod;return r.io==="input"?E.optin===void 0:E.optout===void 0}));g.size>0&&(u.required=Array.from(g)),p.catchall?._zod.def.type==="never"?u.additionalProperties=!1:p.catchall?p.catchall&&(u.additionalProperties=Je(p.catchall,r,{...s,path:[...s.path,"additionalProperties"]})):r.io==="output"&&(u.additionalProperties=!1)},Q_=(t,r,i,s)=>{const u=t._zod.def,p=u.inclusive===!1,d=u.options.map((m,g)=>Je(m,r,{...s,path:[...s.path,p?"oneOf":"anyOf",g]}));p?i.oneOf=d:i.anyOf=d},Y_=(t,r,i,s)=>{const u=t._zod.def,p=Je(u.left,r,{...s,path:[...s.path,"allOf",0]}),d=Je(u.right,r,{...s,path:[...s.path,"allOf",1]}),m=y=>"allOf"in y&&Object.keys(y).length===1,g=[...m(p)?p.allOf:[p],...m(d)?d.allOf:[d]];i.allOf=g},X_=(t,r,i,s)=>{const u=i,p=t._zod.def;u.type="object";const d=p.keyType,g=d._zod.bag?.patterns;if(p.mode==="loose"&&g&&g.size>0){const E=Je(p.valueType,r,{...s,path:[...s.path,"patternProperties","*"]});u.patternProperties={};for(const S of g)u.patternProperties[S.source]=E}else(r.target==="draft-07"||r.target==="draft-2020-12")&&(u.propertyNames=Je(p.keyType,r,{...s,path:[...s.path,"propertyNames"]})),u.additionalProperties=Je(p.valueType,r,{...s,path:[...s.path,"additionalProperties"]});const y=d._zod.values;if(y){const E=[...y].filter(S=>typeof S=="string"||typeof S=="number");E.length>0&&(u.required=E)}},e8=(t,r,i,s)=>{const u=t._zod.def,p=Je(u.innerType,r,s),d=r.seen.get(t);r.target==="openapi-3.0"?(d.ref=u.innerType,i.nullable=!0):i.anyOf=[p,{type:"null"}]},t8=(t,r,i,s)=>{const u=t._zod.def;Je(u.innerType,r,s);const p=r.seen.get(t);p.ref=u.innerType},n8=(t,r,i,s)=>{const u=t._zod.def;Je(u.innerType,r,s);const p=r.seen.get(t);p.ref=u.innerType,i.default=JSON.parse(JSON.stringify(u.defaultValue))},r8=(t,r,i,s)=>{const u=t._zod.def;Je(u.innerType,r,s);const p=r.seen.get(t);p.ref=u.innerType,r.io==="input"&&(i._prefault=JSON.parse(JSON.stringify(u.defaultValue)))},o8=(t,r,i,s)=>{const u=t._zod.def;Je(u.innerType,r,s);const p=r.seen.get(t);p.ref=u.innerType;let d;try{d=u.catchValue(void 0)}catch{throw new Error("Dynamic catch values are not supported in JSON Schema")}i.default=d},i8=(t,r,i,s)=>{const u=t._zod.def,p=u.in._zod.traits.has("$ZodTransform"),d=r.io==="input"?p?u.out:u.in:u.out;Je(d,r,s);const m=r.seen.get(t);m.ref=d},a8=(t,r,i,s)=>{const u=t._zod.def;Je(u.innerType,r,s);const p=r.seen.get(t);p.ref=u.innerType,i.readOnly=!0},Um=(t,r,i,s)=>{const u=t._zod.def;Je(u.innerType,r,s);const p=r.seen.get(t);p.ref=u.innerType},s8=j("ZodISODateTime",(t,r)=>{a3.init(t,r),Ue.init(t,r)});function N(t){return p_(s8,t)}const l8=j("ZodISODate",(t,r)=>{s3.init(t,r),Ue.init(t,r)});function u8(t){return f_(l8,t)}const c8=j("ZodISOTime",(t,r)=>{l3.init(t,r),Ue.init(t,r)});function d8(t){return m_(c8,t)}const p8=j("ZodISODuration",(t,r)=>{u3.init(t,r),Ue.init(t,r)});function f8(t){return v_(p8,t)}const m8=(t,r)=>{xm.init(t,r),t.name="ZodError",Object.defineProperties(t,{format:{value:i=>Q7(t,i)},flatten:{value:i=>K7(t,i)},addIssue:{value:i=>{t.issues.push(i),t.message=JSON.stringify(t.issues,Kl,2)}},addIssues:{value:i=>{t.issues.push(...i),t.message=JSON.stringify(t.issues,Kl,2)}},isEmpty:{get(){return t.issues.length===0}}})},Ft=j("ZodError",m8,{Parent:Error}),v8=vu(Ft),h8=hu(Ft),g8=Aa(Ft),y8=Oa(Ft),_8=e2(Ft),w8=t2(Ft),x8=n2(Ft),E8=r2(Ft),I8=o2(Ft),S8=i2(Ft),k8=a2(Ft),b8=s2(Ft),bf=new WeakMap;function ei(t,r,i){const s=Object.getPrototypeOf(t);let u=bf.get(s);if(u||(u=new Set,bf.set(s,u)),!u.has(r)){u.add(r);for(const p in i){const d=i[p];Object.defineProperty(s,p,{configurable:!0,enumerable:!1,get(){const m=d.bind(this);return Object.defineProperty(this,p,{configurable:!0,writable:!0,enumerable:!0,value:m}),m},set(m){Object.defineProperty(this,p,{configurable:!0,writable:!0,enumerable:!0,value:m})}})}}}const Le=j("ZodType",(t,r)=>(je.init(t,r),Object.assign(t["~standard"],{jsonSchema:{input:ba(t,"input"),output:ba(t,"output")}}),t.toJSONSchema=$_(t,{}),t.def=r,t.type=r.type,Object.defineProperty(t,"_def",{value:r}),t.parse=(i,s)=>v8(t,i,s,{callee:t.parse}),t.safeParse=(i,s)=>g8(t,i,s),t.parseAsync=async(i,s)=>h8(t,i,s,{callee:t.parseAsync}),t.safeParseAsync=async(i,s)=>y8(t,i,s),t.spa=t.safeParseAsync,t.encode=(i,s)=>_8(t,i,s),t.decode=(i,s)=>w8(t,i,s),t.encodeAsync=async(i,s)=>x8(t,i,s),t.decodeAsync=async(i,s)=>E8(t,i,s),t.safeEncode=(i,s)=>I8(t,i,s),t.safeDecode=(i,s)=>S8(t,i,s),t.safeEncodeAsync=async(i,s)=>k8(t,i,s),t.safeDecodeAsync=async(i,s)=>b8(t,i,s),ei(t,"ZodType",{check(...i){const s=this.def;return this.clone(Kn(s,{checks:[...s.checks??[],...i.map(u=>typeof u=="function"?{_zod:{check:u,def:{check:"custom"},onattach:[]}}:u)]}),{parent:!0})},with(...i){return this.check(...i)},clone(i,s){return Qn(this,i,s)},brand(){return this},register(i,s){return i.add(this,s),this},refine(i,s){return this.check(gw(i,s))},superRefine(i,s){return this.check(yw(i,s))},overwrite(i){return this.check(ro(i))},optional(){return Bf(this)},exactOptional(){return ow(this)},nullable(){return Rf(this)},nullish(){return Bf(Rf(this))},nonoptional(i){return cw(this,i)},array(){return P(this)},or(i){return Yn([this,i])},and(i){return X8(this,i)},transform(i){return Pf(this,nw(i))},default(i){return sw(this,i)},prefault(i){return uw(this,i)},catch(i){return pw(this,i)},pipe(i){return Pf(this,i)},readonly(){return vw(this)},describe(i){const s=this.clone();return Wo.add(s,{description:i}),s},meta(...i){if(i.length===0)return Wo.get(this);const s=this.clone();return Wo.add(s,i[0]),s},isOptional(){return this.safeParse(void 0).success},isNullable(){return this.safeParse(null).success},apply(i){return i(this)}}),Object.defineProperty(t,"description",{get(){return Wo.get(t)?.description},configurable:!0}),t)),Zm=j("_ZodString",(t,r)=>{gu.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(s,u,p)=>D_(t,s,u);const i=t._zod.bag;t.format=i.format??null,t.minLength=i.minimum??null,t.maxLength=i.maximum??null,ei(t,"_ZodString",{regex(...s){return this.check(E_(...s))},includes(...s){return this.check(k_(...s))},startsWith(...s){return this.check(b_(...s))},endsWith(...s){return this.check(z_(...s))},min(...s){return this.check(ka(...s))},max(...s){return this.check($m(...s))},length(...s){return this.check(Lm(...s))},nonempty(...s){return this.check(ka(1,...s))},lowercase(s){return this.check(I_(s))},uppercase(s){return this.check(S_(s))},trim(){return this.check(T_())},normalize(...s){return this.check(C_(...s))},toLowerCase(){return this.check(B_())},toUpperCase(){return this.check(R_())},slugify(){return this.check(P_())}})}),z8=j("ZodString",(t,r)=>{gu.init(t,r),Zm.init(t,r),t.email=i=>t.check(W3(C8,i)),t.url=i=>t.check(jm(qm,i)),t.jwt=i=>t.check(d_(Z8,i)),t.emoji=i=>t.check(Q3(T8,i)),t.guid=i=>t.check(kf(zf,i)),t.uuid=i=>t.check(H3(ya,i)),t.uuidv4=i=>t.check(G3(ya,i)),t.uuidv6=i=>t.check(J3(ya,i)),t.uuidv7=i=>t.check(K3(ya,i)),t.nanoid=i=>t.check(Y3(B8,i)),t.guid=i=>t.check(kf(zf,i)),t.cuid=i=>t.check(X3(R8,i)),t.cuid2=i=>t.check(e_(P8,i)),t.ulid=i=>t.check(t_(N8,i)),t.base64=i=>t.check(l_(M8,i)),t.base64url=i=>t.check(u_(F8,i)),t.xid=i=>t.check(n_(A8,i)),t.ksuid=i=>t.check(r_(O8,i)),t.ipv4=i=>t.check(o_(j8,i)),t.ipv6=i=>t.check(i_($8,i)),t.cidrv4=i=>t.check(a_(L8,i)),t.cidrv6=i=>t.check(s_(D8,i)),t.e164=i=>t.check(c_(U8,i)),t.datetime=i=>t.check(N(i)),t.date=i=>t.check(u8(i)),t.time=i=>t.check(d8(i)),t.duration=i=>t.check(f8(i))});function o(t){return V3(z8,t)}const Ue=j("ZodStringFormat",(t,r)=>{$e.init(t,r),Zm.init(t,r)}),C8=j("ZodEmail",(t,r)=>{Q2.init(t,r),Ue.init(t,r)}),zf=j("ZodGUID",(t,r)=>{J2.init(t,r),Ue.init(t,r)}),ya=j("ZodUUID",(t,r)=>{K2.init(t,r),Ue.init(t,r)}),qm=j("ZodURL",(t,r)=>{Y2.init(t,r),Ue.init(t,r)});function Cf(t){return jm(qm,t)}const T8=j("ZodEmoji",(t,r)=>{X2.init(t,r),Ue.init(t,r)}),B8=j("ZodNanoID",(t,r)=>{e3.init(t,r),Ue.init(t,r)}),R8=j("ZodCUID",(t,r)=>{t3.init(t,r),Ue.init(t,r)}),P8=j("ZodCUID2",(t,r)=>{n3.init(t,r),Ue.init(t,r)}),N8=j("ZodULID",(t,r)=>{r3.init(t,r),Ue.init(t,r)}),A8=j("ZodXID",(t,r)=>{o3.init(t,r),Ue.init(t,r)}),O8=j("ZodKSUID",(t,r)=>{i3.init(t,r),Ue.init(t,r)}),j8=j("ZodIPv4",(t,r)=>{c3.init(t,r),Ue.init(t,r)}),$8=j("ZodIPv6",(t,r)=>{d3.init(t,r),Ue.init(t,r)}),L8=j("ZodCIDRv4",(t,r)=>{p3.init(t,r),Ue.init(t,r)}),D8=j("ZodCIDRv6",(t,r)=>{f3.init(t,r),Ue.init(t,r)}),M8=j("ZodBase64",(t,r)=>{m3.init(t,r),Ue.init(t,r)}),F8=j("ZodBase64URL",(t,r)=>{h3.init(t,r),Ue.init(t,r)}),U8=j("ZodE164",(t,r)=>{g3.init(t,r),Ue.init(t,r)}),Z8=j("ZodJWT",(t,r)=>{_3.init(t,r),Ue.init(t,r)}),Vm=j("ZodNumber",(t,r)=>{Rm.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(s,u,p)=>M_(t,s,u),ei(t,"ZodNumber",{gt(s,u){return this.check(Sa(s,u))},gte(s,u){return this.check(Un(s,u))},min(s,u){return this.check(Un(s,u))},lt(s,u){return this.check(Ia(s,u))},lte(s,u){return this.check(Gr(s,u))},max(s,u){return this.check(Gr(s,u))},int(s){return this.check(Be(s))},safe(s){return this.check(Be(s))},positive(s){return this.check(Sa(0,s))},nonnegative(s){return this.check(Un(0,s))},negative(s){return this.check(Ia(0,s))},nonpositive(s){return this.check(Gr(0,s))},multipleOf(s,u){return this.check(Yl(s,u))},step(s,u){return this.check(Yl(s,u))},finite(){return this}});const i=t._zod.bag;t.minValue=Math.max(i.minimum??Number.NEGATIVE_INFINITY,i.exclusiveMinimum??Number.NEGATIVE_INFINITY)??null,t.maxValue=Math.min(i.maximum??Number.POSITIVE_INFINITY,i.exclusiveMaximum??Number.POSITIVE_INFINITY)??null,t.isInt=(i.format??"").includes("int")||Number.isSafeInteger(i.multipleOf??.5),t.isFinite=!0,t.format=i.format??null});function pr(t){return h_(Vm,t)}const q8=j("ZodNumberFormat",(t,r)=>{w3.init(t,r),Vm.init(t,r)});function Be(t){return g_(q8,t)}const V8=j("ZodBoolean",(t,r)=>{x3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>F_(t,i,s)});function Z(t){return y_(V8,t)}const W8=j("ZodBigInt",(t,r)=>{E3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(s,u,p)=>U_(t,s),t.gte=(s,u)=>t.check(Un(s,u)),t.min=(s,u)=>t.check(Un(s,u)),t.gt=(s,u)=>t.check(Sa(s,u)),t.gte=(s,u)=>t.check(Un(s,u)),t.min=(s,u)=>t.check(Un(s,u)),t.lt=(s,u)=>t.check(Ia(s,u)),t.lte=(s,u)=>t.check(Gr(s,u)),t.max=(s,u)=>t.check(Gr(s,u)),t.positive=s=>t.check(Sa(BigInt(0),s)),t.negative=s=>t.check(Ia(BigInt(0),s)),t.nonpositive=s=>t.check(Gr(BigInt(0),s)),t.nonnegative=s=>t.check(Un(BigInt(0),s)),t.multipleOf=(s,u)=>t.check(Yl(s,u));const i=t._zod.bag;t.minValue=i.minimum??null,t.maxValue=i.maximum??null,t.format=i.format??null}),H8=j("ZodUnknown",(t,r)=>{I3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>q_()});function Jn(){return w_(H8)}const G8=j("ZodNever",(t,r)=>{S3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>Z_(t,i,s)});function $a(t){return x_(G8,t)}const J8=j("ZodArray",(t,r)=>{k3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>J_(t,i,s,u),t.element=r.element,ei(t,"ZodArray",{min(i,s){return this.check(ka(i,s))},nonempty(i){return this.check(ka(1,i))},max(i,s){return this.check($m(i,s))},length(i,s){return this.check(Lm(i,s))},unwrap(){return this.element}})});function P(t,r){return N_(J8,t,r)}const K8=j("ZodObject",(t,r)=>{z3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>K_(t,i,s,u),ze(t,"shape",()=>r.shape),ei(t,"ZodObject",{keyof(){return Kt(Object.keys(this._zod.def.shape))},catchall(i){return this.clone({...this._zod.def,catchall:i})},passthrough(){return this.clone({...this._zod.def,catchall:Jn()})},loose(){return this.clone({...this._zod.def,catchall:Jn()})},strict(){return this.clone({...this._zod.def,catchall:$a()})},strip(){return this.clone({...this._zod.def,catchall:void 0})},extend(i){return q7(this,i)},safeExtend(i){return V7(this,i)},merge(i){return W7(this,i)},pick(i){return U7(this,i)},omit(i){return Z7(this,i)},partial(...i){return H7(Gm,this,i[0])},required(...i){return G7(Jm,this,i[0])}})});function h(t,r){const i={type:"object",shape:t??{},...ie(r)};return new K8(i)}const Wm=j("ZodUnion",(t,r)=>{Am.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>Q_(t,i,s,u),t.options=r.options});function Yn(t,r){return new Wm({type:"union",options:t,...ie(r)})}const Q8=j("ZodDiscriminatedUnion",(t,r)=>{Wm.init(t,r),C3.init(t,r)});function Hm(t,r,i){return new Q8({type:"union",options:r,discriminator:t,...ie(i)})}const Y8=j("ZodIntersection",(t,r)=>{T3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>Y_(t,i,s,u)});function X8(t,r){return new Y8({type:"intersection",left:t,right:r})}const Tf=j("ZodRecord",(t,r)=>{B3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>X_(t,i,s,u),t.keyType=r.keyType,t.valueType=r.valueType});function fe(t,r,i){return!r||!r._zod?new Tf({type:"record",keyType:o(),valueType:t,...ie(r)}):new Tf({type:"record",keyType:t,valueType:r,...ie(i)})}const Xl=j("ZodEnum",(t,r)=>{R3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(s,u,p)=>V_(t,s,u),t.enum=r.entries,t.options=Object.values(r.entries);const i=new Set(Object.keys(r.entries));t.extract=(s,u)=>{const p={};for(const d of s)if(i.has(d))p[d]=r.entries[d];else throw new Error(`Key ${d} not found in enum`);return new Xl({...r,checks:[],...ie(u),entries:p})},t.exclude=(s,u)=>{const p={...r.entries};for(const d of s)if(i.has(d))delete p[d];else throw new Error(`Key ${d} not found in enum`);return new Xl({...r,checks:[],...ie(u),entries:p})}});function Kt(t,r){const i=Array.isArray(t)?Object.fromEntries(t.map(s=>[s,s])):t;return new Xl({type:"enum",entries:i,...ie(r)})}const ew=j("ZodLiteral",(t,r)=>{P3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>W_(t,i,s),t.values=new Set(r.values),Object.defineProperty(t,"value",{get(){if(r.values.length>1)throw new Error("This schema contains multiple valid literal values. Use `.values` instead.");return r.values[0]}})});function x(t,r){return new ew({type:"literal",values:Array.isArray(t)?t:[t],...ie(r)})}const tw=j("ZodTransform",(t,r)=>{N3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>G_(t,i),t._zod.parse=(i,s)=>{if(s.direction==="backward")throw new hm(t.constructor.name);i.addIssue=p=>{if(typeof p=="string")i.issues.push(Yo(p,i.value,r));else{const d=p;d.fatal&&(d.continue=!1),d.code??(d.code="custom"),d.input??(d.input=i.value),d.inst??(d.inst=t),i.issues.push(Yo(d))}};const u=r.transform(i.value,i);return u instanceof Promise?u.then(p=>(i.value=p,i.fallback=!0,i)):(i.value=u,i.fallback=!0,i)}});function nw(t){return new tw({type:"transform",transform:t})}const Gm=j("ZodOptional",(t,r)=>{Om.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>Um(t,i,s,u),t.unwrap=()=>t._zod.def.innerType});function Bf(t){return new Gm({type:"optional",innerType:t})}const rw=j("ZodExactOptional",(t,r)=>{A3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>Um(t,i,s,u),t.unwrap=()=>t._zod.def.innerType});function ow(t){return new rw({type:"optional",innerType:t})}const iw=j("ZodNullable",(t,r)=>{O3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>e8(t,i,s,u),t.unwrap=()=>t._zod.def.innerType});function Rf(t){return new iw({type:"nullable",innerType:t})}const aw=j("ZodDefault",(t,r)=>{j3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>n8(t,i,s,u),t.unwrap=()=>t._zod.def.innerType,t.removeDefault=t.unwrap});function sw(t,r){return new aw({type:"default",innerType:t,get defaultValue(){return typeof r=="function"?r():_m(r)}})}const lw=j("ZodPrefault",(t,r)=>{$3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>r8(t,i,s,u),t.unwrap=()=>t._zod.def.innerType});function uw(t,r){return new lw({type:"prefault",innerType:t,get defaultValue(){return typeof r=="function"?r():_m(r)}})}const Jm=j("ZodNonOptional",(t,r)=>{L3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>t8(t,i,s,u),t.unwrap=()=>t._zod.def.innerType});function cw(t,r){return new Jm({type:"nonoptional",innerType:t,...ie(r)})}const dw=j("ZodCatch",(t,r)=>{D3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>o8(t,i,s,u),t.unwrap=()=>t._zod.def.innerType,t.removeCatch=t.unwrap});function pw(t,r){return new dw({type:"catch",innerType:t,catchValue:typeof r=="function"?r:()=>r})}const fw=j("ZodPipe",(t,r)=>{M3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>i8(t,i,s,u),t.in=r.in,t.out=r.out});function Pf(t,r){return new fw({type:"pipe",in:t,out:r})}const mw=j("ZodReadonly",(t,r)=>{F3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>a8(t,i,s,u),t.unwrap=()=>t._zod.def.innerType});function vw(t){return new mw({type:"readonly",innerType:t})}const hw=j("ZodCustom",(t,r)=>{U3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>H_(t,i)});function gw(t,r={}){return A_(hw,t,r)}function yw(t,r){return O_(t,r)}function w(t){return __(W8,t)}const _w=h({MaxMessageLength:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),SupportsAttachments:Z(),SupportsChildConversations:Z()}),ti=h({account_id:o(),provider:o()});h({dir:o().optional(),name:o().min(1),provider:o().min(1),scope:o().optional()});h({agent:o(),status:o()});const ww=h({agent_id:o(),parent_tool_use_id:o()});h({dir:o().optional(),env:fe(o(),o()).optional(),name:o().optional(),scope:o().optional(),suspended:Z().optional(),tmux_alias:o().optional(),work_dir:o().optional()});h({agent:o(),bytes:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),prompt:o()});h({provider:o().optional(),scope:o().optional(),suspended:Z().optional()});h({provider:o().optional(),scope:o().optional(),suspended:Z().optional()});const xw=h({dir:o().optional(),is_pool:Z().optional(),name:o(),origin:o(),provider:o().optional(),scope:o().optional(),suspended:Z()}),Ew=h({acp_args:P(o()).optional(),acp_command:o().optional(),args:P(o()).nullish(),command:o().optional(),display_name:o().optional(),env:fe(o(),o()).optional(),origin:o(),prompt_flag:o().optional(),prompt_mode:o().optional(),ready_delay_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional()});h({event_cursor:o(),request_id:o(),status:o()});h({event_cursor:o(),request_id:o()});h({assignee:o().optional()});h({reason:o().max(1024).optional()});h({assignee:o().optional(),description:o().optional(),labels:P(o()).nullish(),metadata:fe(o(),o()).optional(),parent:o().optional(),priority:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),rig:o().optional(),title:o().min(1),type:o().optional()});h({assignee:o().optional(),description:o().optional(),labels:P(o()).nullish(),metadata:fe(o(),o()).optional(),parent:o().nullish(),priority:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),remove_labels:P(o()).nullish(),status:o().optional(),title:o().optional(),type:o().optional()});const Iw=Kt(["active","ended"]),yu=h({conversation_id:o(),provider:o(),session_id:o()});h({bootstrap_profile:Kt(["k8s-cell","kubernetes","kubernetes-cell","single-host-compat"]).optional(),dir:o().min(1),provider:o().min(1).optional(),start_command:o().optional()});const _u=h({name:o(),path:o(),request_id:o()});h({agent_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),name:o(),path:o(),provider:o().optional(),rig_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_template:o().optional(),suspended:Z(),uptime_sec:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),version:o().optional()});const Sw=h({error:o().optional(),name:o(),path:o(),phases_completed:P(o()).nullish(),running:Z(),status:o().optional()}),ni=h({name:o(),path:o()});h({suspended:Z().optional()});const wu=h({name:o(),path:o(),request_id:o()}),kw=h({dir:o().optional(),is_pool:Z().optional(),name:o(),provider:o().optional(),scope:o().optional(),suspended:Z()}),bw=h({agents:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),providers:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),rigs:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({agents:P(xw).nullable(),patches:bw,providers:fe(o(),Ew)});const zw=h({agent_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),provider_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),rig_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})}),Cw=h({name:o(),path:o(),prefix:o().optional(),suspended:Z()});h({errors:P(o()).nullable(),valid:Z(),warnings:P(o()).nullable()});h({GroupID:o(),Handle:o(),ID:o(),Metadata:fe(o(),o()),Public:Z(),SessionID:o()});const Tw=Kt(["dm","room","thread"]),Yt=h({account_id:o(),conversation_id:o(),kind:Tw,parent_conversation_id:o().optional(),provider:o(),scope_id:o()});h({items:P(o()).nullish()});h({closed:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),complete:Z(),convoy_id:o(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({items:P(o()).nullish(),rig:o().optional(),title:o().min(1)});const Bw=h({closed:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({items:P(o()).nullish()});const Rw=h({BindingGeneration:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),Conversation:Yt,ID:o(),LastMessageID:o(),LastPublishedAt:N({offset:!0}),Metadata:fe(o(),o()),SchemaVersion:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),SessionID:o(),SourceSessionID:o()}),Pw=h({depends_on_id:o(),issue_id:o(),type:o()}),fr=h({assignee:o().optional(),created_at:N({offset:!0}),dependencies:P(Pw).nullish(),description:o().optional(),ephemeral:Z().optional(),from:o().optional(),id:o(),issue_type:o(),labels:P(o()).nullish(),metadata:fe(o(),o()).optional(),needs:P(o()).nullish(),parent:o().optional(),priority:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).nullish(),ref:o().optional(),status:o(),title:o(),updated_at:N({offset:!0}).optional()});h({children:P(fr).nullable()});const gr=h({bead:fr});h({children:P(fr).nullish(),convoy:fr.optional(),progress:Bw.optional()});const Nw=h({location:o().optional(),message:o().optional(),value:Jn().optional()});h({detail:o().optional(),errors:P(Nw).nullish(),instance:Cf().optional(),status:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),title:o().optional(),type:Cf().optional().default("about:blank")});h({status:o()});h({actor:o().min(1),message:o().optional(),subject:o().optional(),type:o().min(1)});const Aw=h({seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),ts:N({offset:!0}),type:o()}),Ow=h({compression_status:Kt(["pending","complete"]),first_seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),last_seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),path:o()});h({anchor_event:Aw.optional(),archive:Ow.optional(),reason:o().optional(),rotated:Z()});h({account_id:o().min(1),callback_url:o().optional(),capabilities:_w.optional(),name:o().optional(),provider:o().min(1)});h({account_id:o(),name:o(),provider:o(),status:o()});h({account_id:o().min(1),provider:o().min(1)});h({conversation:Yt.optional(),metadata:fe(o(),o()).optional(),session_id:o().min(1)});h({default_handle:o().optional(),metadata:fe(o(),o()).optional(),mode:o().optional(),root_conversation:Yt.optional()});h({conversation:Yt.optional(),idempotency_key:o().optional(),reply_to_message_id:o().optional(),session_id:o().min(1),text:o().optional()});h({group_id:o().min(1),handle:o().min(1)});h({group_id:o().min(1),handle:o().min(1),metadata:fe(o(),o()).optional(),public:Z().optional(),session_id:o().min(1)});h({conversation:Yt.optional(),sequence:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),session_id:o().min(1)});h({conversation:Yt.optional(),session_id:o().min(1)});const Km=h({display_name:o(),id:o(),is_bot:Z()}),Qm=h({mime_type:o(),provider_id:o(),url:o()}),Ym=h({actor:Km,attachments:P(Qm).nullish(),conversation:Yt,dedup_key:o().optional(),explicit_target:o().optional(),provider_message_id:o(),received_at:N({offset:!0}),reply_to_message_id:o().optional(),text:o()});h({account_id:o().optional(),message:Ym.optional(),payload:o().optional(),provider:o().optional()});const jw=h({account_id:o(),name:o(),provider:o()}),$w=h({AllowUntargetedPublication:Z(),Enabled:Z(),MaxPeerTriggeredPublishes:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),MaxTotalPeerDeliveries:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({DefaultHandle:o(),FanoutPolicy:$w,ID:o(),LastAddressedHandle:o(),Metadata:fe(o(),o()),Mode:o(),RootConversation:Yt,SchemaVersion:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({scope_kind:o().optional(),scope_ref:o().optional(),target:o().min(1),vars:fe(o(),o()).optional()});const Xm=h({from:o(),kind:o().optional(),to:o()}),Lw=h({id:o(),kind:o(),scope_ref:o().optional(),title:o()}),Dw=h({edges:P(Xm).nullable(),nodes:P(Lw).nullable()}),ev=h({started_at:o(),status:o(),target:o(),updated_at:o(),workflow_id:o()});h({formula:o(),partial:Z(),partial_errors:P(o()).nullish(),recent_runs:P(ev).nullable(),run_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const Mw=h({assignee:o().optional(),id:o(),kind:o(),labels:P(o()).nullish(),metadata:fe(o(),o()).optional(),title:o(),type:o().optional()}),tv=h({default:Jn().optional(),description:o().optional(),enum:P(o()).nullish(),name:o(),pattern:o().optional(),required:Z().optional(),type:o()});h({deps:P(Xm).nullable(),description:o(),name:o(),preview:Dw,steps:P(Mw).nullable(),var_defs:P(tv).nullable(),version:o()});const Fw=h({description:o(),name:o(),recent_runs:P(ev).nullable(),run_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),var_defs:P(tv).nullable(),version:o()});h({items:P(Fw).nullable(),partial:Z(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const Uw=h({ahead:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),behind:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),branch:o(),changed_files:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),clean:Z()}),xu=h({conversation_id:o(),mode:o(),provider:o()}),Zw=h({Match:o(),TargetSessionID:o(),UpdateCursor:Z()});h({city:o().optional(),status:o(),uptime_sec:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),version:o().optional()});const oo=h({timestamp:o()}),Eu=h({actor:o(),conversation_id:o(),provider:o(),target_session:o()});h({items:P(fr).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({items:P(jw).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const qw=fe(o(),$a());h({partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),unread:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({body:o().optional(),from:o().optional(),subject:o().optional()});h({body:o().optional(),from:o().optional(),rig:o().optional(),subject:o().min(1),to:o().min(1)});const nv=h({body:o(),cc:P(o()).nullish(),created_at:N({offset:!0}),from:o(),id:o(),priority:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),read:Z(),reply_to:o().optional(),rig:o().optional(),subject:o(),thread_id:o().optional(),to:o()}),mt=h({message:nv.optional(),rig:o()});h({items:P(nv).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const rv=h({attached_bead_id:o().optional(),bead_id:o().optional(),detail_available:Z().optional(),id:o(),logical_bead_id:o().optional(),root_bead_id:o().optional(),root_store_ref:o().optional(),run_detail_available:Z().optional(),scope_kind:o(),scope_ref:o(),started_at:o(),status:o(),store_ref:o().optional(),target:o(),title:o(),type:o(),updated_at:o(),workflow_id:o().optional()});h({items:P(rv).nullable(),partial:Z(),partial_errors:P(o()).nullish()});const ve=fe(o(),$a());h({status:o()});h({id:o().optional(),status:o()});const Vw=h({label:o(),value:o()}),Ww=h({due:Z(),last_run:o().optional(),last_run_outcome:o().optional(),name:o(),reason:o(),rig:o().optional(),scoped_name:o()});h({checks:P(Ww).nullable()});h({bead_id:o(),created_at:o(),labels:P(o()).nullable(),output:o(),store_ref:o()});const Hw=h({bead_id:o(),capture_output:Z(),created_at:o(),duration_ms:o().optional(),error:o().optional(),exit_code:o().optional(),has_output:Z(),labels:P(o()).nullable(),name:o(),rig:o().optional(),scoped_name:o(),signal:o().optional(),store_ref:o(),wisp_root_id:o().optional()});h({entries:P(Hw).nullable()});const Gw=h({capture_output:Z(),check:o().optional(),description:o().optional(),enabled:Z(),exec:o().optional(),formula:o().optional(),gate:o().optional(),interval:o().optional(),name:o(),on:o().optional(),pool:o().optional(),rig:o().optional(),schedule:o().optional(),scoped_name:o(),timeout:o().optional(),timeout_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),trigger:o().optional(),type:o()});h({orders:P(Gw).nullable()});h({items:P(rv).nullable(),partial:Z(),partial_errors:P(o()).nullish()});const Iu=h({conversation_id:o(),message_id:o(),provider:o(),session:o()}),Su=h({role:o(),text:o(),timestamp:o().optional()}),Jw=h({name:o(),path:o().optional(),ref:o().optional(),source:o().optional()});h({packs:P(Jw).nullable()});const La=h({has_older_messages:Z(),returned_message_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),total_compactions:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),total_message_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),truncated_before_message:o().optional()}),ov=h({agent:o(),format:o(),pagination:La.optional(),turns:P(Su).nullable()});h({agent_patch:o().optional(),provider_patch:o().optional(),rig_patch:o().optional(),status:o()});h({agent_patch:o().optional(),provider_patch:o().optional(),rig_patch:o().optional(),status:o()});const ku=h({kind:o(),metadata:fe(o(),o()).optional(),options:P(o()).nullish(),prompt:o().optional(),request_id:o()}),Kw=h({Check:o().nullable(),DrainTimeout:o().nullable(),Max:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).nullable(),Min:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).nullable(),OnBoot:o().nullable(),OnDeath:o().nullable()}),Qw=h({AppendFragments:P(o()).nullable(),Attach:Z().nullable(),DefaultSlingFormula:o().nullable(),DependsOn:P(o()).nullable(),Dir:o(),Env:fe(o(),o()),EnvRemove:P(o()).nullable(),HooksInstalled:Z().nullable(),IdleTimeout:o().nullable(),InjectAssignedSkills:Z().nullable(),InjectFragments:P(o()).nullable(),InjectFragmentsAppend:P(o()).nullable(),InstallAgentHooks:P(o()).nullable(),InstallAgentHooksAppend:P(o()).nullable(),Lifecycle:o().nullable(),MCP:P(o()).nullable(),MCPAppend:P(o()).nullable(),MaxActiveSessions:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).nullable(),MaxSessionAge:o().nullable(),MaxSessionAgeJitter:o().nullable(),MinActiveSessions:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).nullable(),MouseMode:o().nullable(),Name:o(),Nudge:o().nullable(),OptionDefaults:fe(o(),o()),OverlayDir:o().nullable(),Pool:Kw,PreStart:P(o()).nullable(),PreStartAppend:P(o()).nullable(),PromptTemplate:o().nullable(),Provider:o().nullable(),ResumeCommand:o().nullable(),ScaleCheck:o().nullable(),Scope:o().nullable(),Session:o().nullable(),SessionLive:P(o()).nullable(),SessionLiveAppend:P(o()).nullable(),SessionSetup:P(o()).nullable(),SessionSetupAppend:P(o()).nullable(),SessionSetupScript:o().nullable(),Skills:P(o()).nullable(),SkillsAppend:P(o()).nullable(),SleepAfterIdle:o().nullable(),StartCommand:o().nullable(),Suspended:Z().nullable(),TmuxAlias:o().nullable(),WakeMode:o().nullable(),WorkDir:o().nullable()});h({items:P(Qw).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const bu=h({host:o(),port:o(),scope_kind:o(),scope_name:o(),source:o(),user:o()}),zu=h({layer:o(),new_id:o(),old_id:o().optional(),scope_root:o(),source:o()});h({acp_args:P(o()).nullish(),acp_command:o().optional(),args:P(o()).nullish(),args_append:P(o()).nullish(),base:o().optional(),command:o().optional(),display_name:o().optional(),env:fe(o(),o()).optional(),name:o().min(1),option_defaults:fe(o(),o()).optional(),options_schema_merge:o().optional(),prompt_flag:o().optional(),prompt_mode:o().optional(),ready_delay_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional()});h({provider:o(),status:o()});const Yw=h({choices:P(Vw).nullable(),default:o(),key:o(),label:o(),type:o()}),Xw=h({ACPArgs:P(o()).nullable(),ACPCommand:o().nullable(),AcceptStartupDialogs:Z().nullable(),Args:P(o()).nullable(),ArgsAppend:P(o()).nullable(),Base:o().nullable(),Command:o().nullable(),Env:fe(o(),o()),EnvRemove:P(o()).nullable(),Name:o(),OptionsSchemaMerge:o().nullable(),PromptFlag:o().nullable(),PromptMode:o().nullable(),ReadyDelayMs:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).nullable(),Replace:Z()});h({items:P(Xw).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({accept_startup_dialogs:Z().optional(),acp_args:P(o()).nullish(),acp_command:o().optional(),args:P(o()).nullish(),command:o().optional(),env:fe(o(),o()).optional(),name:o().optional(),prompt_flag:o().optional(),prompt_mode:o().optional(),ready_delay_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional()});const e5=h({builtin:Z(),city_level:Z(),display_name:o().optional(),effective_defaults:fe(o(),o()).optional(),name:o(),options_schema:P(Yw).nullish()});h({items:P(e5).nullable(),next_cursor:o().optional(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const t5=h({detail:o().optional(),display_name:o(),status:o()});h({providers:fe(o(),t5)});const n5=h({acp_args:P(o()).optional(),acp_command:o().optional(),args:P(o()).nullish(),builtin:Z(),city_level:Z(),command:o().optional(),display_name:o().optional(),env:fe(o(),o()).optional(),name:o(),prompt_flag:o().optional(),prompt_mode:o().optional(),ready_delay_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional()});h({items:P(n5).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const r5=h({acp_args:P(o()).optional(),acp_command:o().optional(),args:P(o()).nullish(),command:o().optional(),display_name:o().optional(),env:fe(o(),o()).optional(),prompt_flag:o().optional(),prompt_mode:o().optional(),ready_delay_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional()});h({acp_args:P(o()).nullish(),acp_command:o().optional(),args:P(o()).nullish(),args_append:P(o()).nullish(),base:o().optional(),command:o().optional(),display_name:o().optional(),env:fe(o(),o()).optional(),option_defaults:fe(o(),o()).optional(),options_schema_merge:o().optional(),prompt_flag:o().optional(),prompt_mode:o().optional(),ready_delay_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional()});const o5=h({Conversation:Yt,Delivered:Z(),FailureKind:o(),MessageID:o(),Metadata:fe(o(),o()),RetryAfter:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})}),i5=h({detail:o().optional(),display_name:o(),kind:o(),name:o(),status:o()});h({items:fe(o(),i5)});const Cu=h({error_code:o(),error_message:o(),operation:Kt(["city.create","city.unregister","session.create","session.message","session.submit"]),request_id:o()});h({action:o(),failed:P(o()).nullish(),killed:P(o()).nullish(),rig:o(),status:o()});h({default_branch:o().optional(),name:o().min(1),path:o().min(1),prefix:o().optional()});h({rig:o(),status:o()});const a5=h({DefaultBranch:o().nullable(),FormulaVars:fe(o(),o()),Name:o(),Path:o().nullable(),Prefix:o().nullable(),Suspended:Z().nullable()});h({items:P(a5).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({default_branch:o().optional(),name:o().optional(),path:o().optional(),prefix:o().optional(),suspended:Z().optional()});const s5=h({agent_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),default_branch:o().optional(),git:Uw.optional(),last_activity:N({offset:!0}).optional(),name:o(),path:o(),prefix:o().optional(),running_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),suspended:Z()});h({items:P(s5).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({default_branch:o().optional(),path:o().optional(),prefix:o().optional(),suspended:Z().optional()});const Tu=h({prior_archive:o(),prior_first_seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),prior_last_seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})}),l5=fe(o(),$a());h({action:o(),service:o(),status:o()});const iv=h({activity:o()});h({messages:P(Jn()).nullable(),status:o().optional()});h({agents:P(ww).nullable()});const Bu=h({BindingGeneration:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),BoundAt:N({offset:!0}),Conversation:Yt,ExpiresAt:N({offset:!0}).nullable(),ID:o(),Metadata:fe(o(),o()),SchemaVersion:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),SessionID:o(),Status:Iw});h({unbound:P(Bu).nullable()});h({items:P(Bu).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({alias:o().optional(),async:Z().optional(),kind:o().optional(),message:o().optional(),name:o().optional(),options:fe(o(),o()).optional(),project_id:o().optional(),session_name:o().optional(),title:o().optional()});const Ru=h({bead_id:o(),bead_status:o().optional(),reason:o().optional(),session_id:o(),template:o().optional()}),u5=h({attached:Z(),last_activity:N({offset:!0}).optional(),name:o()}),c5=h({active_bead:o().optional(),activity:o().optional(),available:Z(),context_pct:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),context_window:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),description:o().optional(),display_name:o().optional(),last_output:o().optional(),model:o().optional(),name:o(),pool:o().optional(),provider:o().optional(),rig:o().optional(),running:Z(),session:u5.optional(),state:o(),suspended:Z(),unavailable_reason:o().optional()});h({items:P(c5).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const yr=h({reason:o().optional(),session_id:o(),template:o().optional()});h({message:o().min(1).regex(/\S/)});const Pu=h({request_id:o(),session_id:o()});h({alias:o().optional(),title:o().min(1).optional()});h({pending:ku.optional(),supported:Z()});h({permission_mode:o().min(1).regex(/\S/)});const av=Jn();h({title:o().min(1)});h({action:o().min(1),metadata:fe(o(),o()).optional(),request_id:o().optional(),text:o().optional()});h({id:o(),status:o()});Yn([iv,ku,oo]);const d5=h({format:o(),id:o(),pagination:La.optional(),provider:o(),template:o(),turns:P(Su).nullable()}),p5=h({format:o(),id:o(),messages:P(av).nullable(),pagination:La.optional(),provider:o(),template:o()}),Nu=h({intent:o(),queued:Z(),request_id:o(),session_id:o()});h({format:o(),id:o(),messages:P(av).nullish(),pagination:La.optional(),provider:o(),template:o(),turns:P(Su).nullish()});h({attached_bead_id:o().optional(),bead:o().optional(),force:Z().optional(),formula:o().optional(),rig:o().optional(),scope_kind:o().optional(),scope_ref:o().optional(),target:o().min(1),title:o().optional(),vars:fe(o(),o()).optional()});h({attached_bead_id:o().optional(),bead:o().optional(),formula:o().optional(),mode:o().optional(),root_bead_id:o().optional(),status:o(),target:o(),warnings:P(o()).nullish(),workflow_id:o().optional()});const f5=h({allow_websockets:Z().optional(),hostname:o().optional(),kind:o().optional(),local_state:o(),mount_path:o(),publication_state:o(),publish_mode:o(),reason:o().optional(),service_name:o(),state:o().optional(),state_root:o(),updated_at:N({offset:!0}),url:o().optional(),visibility:o().optional(),workflow_contract:o().optional()});h({items:P(f5).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const m5=h({quarantined:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),running:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),suspended:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})}),v5=h({draining:Z().optional(),expanded:Z().optional(),group_name:o().optional(),name:o(),qualified_name:o(),running:Z(),scale_label:o().optional(),scope:o(),session_name:o().optional(),suspended:Z()}),h5=h({total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),unread:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})}),g5=h({identity:o(),mode:o(),status:o()}),y5=h({suspended:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})}),_5=h({name:o(),path:o(),suspended:Z()}),w5=h({active:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),suspended:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})}),x5=h({last_gc_at:o().optional(),last_gc_status:o().optional(),live_rows:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),path:o(),ratio_mb_per_row:pr(),size_bytes:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),threshold_mb_per_row:pr(),warning:Z()}),E5=h({in_progress:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),open:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),ready:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({agent_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),agent_details:P(v5).nullish(),agents:m5,mail:h5,name:o(),named_session_details:P(g5).nullish(),partial:Z().optional(),partial_errors:P(o()).nullish(),path:o(),rig_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),rig_details:P(_5).nullish(),rigs:y5,running:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_counts_detail:w5.optional(),store_health:x5.optional(),suspended:Z(),uptime_sec:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),version:o().optional(),work:E5});const Au=h({after_bytes:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),before_bytes:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),duration_s:pr(),snapshot_path:o()}),Ou=h({duration_s:pr(),error_msg:o(),snapshot_path:o().optional(),stage:o()}),I5=h({supports_follow_up:Z(),supports_interrupt_now:Z()}),sv=h({active_bead:o().optional(),activity:o().optional(),agent_kind:o().optional(),alias:o().optional(),attached:Z(),configured_named_session:Z().optional(),context_pct:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),context_window:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),created_at:o(),display_name:o().optional(),id:o(),kind:o().optional(),last_active:o().optional(),last_nudge_delivered_at:o().optional(),last_output:o().optional(),metadata:fe(o(),o()).optional(),model:o().optional(),options:fe(o(),o()).optional(),pool:o().optional(),provider:o(),reason:o().optional(),rig:o().optional(),running:Z(),session_name:o(),state:o(),submission_capabilities:I5.optional(),template:o(),title:o()});h({items:P(sv).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const ju=h({request_id:o(),session:sv}),S5=Kt(["default","follow_up","interrupt_now"]);h({intent:S5.optional(),message:o().min(1).regex(/\S/)});h({items:P(Sw).nullable(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const $u=h({avg60:pr(),consecutive_skips:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),max_consecutive_skips:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),outcome:o(),threshold:pr(),trigger:o().optional()}),Lu=h({client_addr:o().optional(),mode:Kt(["destructive","preserve_sessions","unknown"]),signal:o().optional(),source:Kt(["signal","socket_stop"])}),k5=h({phase:o().optional(),phases_completed:P(o()).nullish(),ready:Z()});h({build_id:o().optional(),cities_running:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),cities_total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),startup:k5.optional(),status:o(),uptime_sec:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),version:o()});const b5=Kt(["inbound","outbound"]),z5=Kt(["live","hydrated"]),Du=h({Actor:Km,Attachments:P(Qm).nullable(),Conversation:Yt,CreatedAt:N({offset:!0}),ExplicitTarget:o(),ID:o(),Kind:b5,Metadata:fe(o(),o()),Provenance:z5,ProviderMessageID:o(),ReplyToMessageID:o(),SchemaVersion:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),Sequence:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),SourceSessionID:o(),Text:o()});h({Binding:Bu,GroupRoute:Zw,Message:Ym,TargetSessionID:o(),TranscriptEntry:Du});h({items:P(Du).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({DeliveryContext:Rw,Receipt:o5,TranscriptEntry:Du});const Mu=h({count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o()}),Fu=h({agent_name:o().optional(),bead_id:o().optional(),cache_creation_tokens:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),cache_read_tokens:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),completion_tokens:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),cost_usd_estimate:pr().optional(),delivered:Z().optional(),duration_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),error:o().optional(),finished_at:N({offset:!0}),latency_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),model:o().optional(),op_id:o(),operation:o(),prompt_sha:o().optional(),prompt_tokens:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),prompt_version:o().optional(),provider:o().optional(),queued:Z().optional(),result:o(),session_id:o().optional(),session_name:o().optional(),started_at:N({offset:!0}),template:o().optional(),transport:o().optional()}),lv=Yn([ti,gr,yu,_u,ni,wu,xu,Eu,mt,ve,Iu,bu,zu,Cu,Tu,ju,Ru,yr,Pu,Nu,Au,Ou,$u,Lu,Mu,Fu]),C5=h({active_attempt:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),attempt_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),max_attempts:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional()}),uv=h({assignee:o().optional(),attempt:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),id:o(),kind:o(),logical_bead_id:o().optional(),metadata:fe(o(),o()),scope_ref:o().optional(),status:o(),step_ref:o().optional(),title:o()});h({closed:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),deleted:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),partial:Z().optional(),partial_errors:P(o()).nullish(),workflow_id:o()});const eu=h({from:o(),kind:o().optional(),to:o()});h({beads:P(fr).nullable(),deps:P(eu).nullable(),root:fr});const L=h({attempt_summary:C5.optional(),bead:uv,changed_fields:P(o()).nullable(),event_seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),event_ts:o(),event_type:o(),logical_node_id:o(),requires_resync:Z().optional(),root_bead_id:o(),root_store_ref:o(),scope_kind:o(),scope_ref:o(),type:o(),watch_generation:o(),workflow_id:o(),workflow_seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({actor:o(),message:o().optional(),payload:lv.optional(),run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:o(),workflow:L.optional()});h({actor:o(),city:o(),message:o().optional(),payload:lv.optional(),run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:o(),workflow:L.optional()});const T5=h({actor:o(),message:o().optional(),payload:gr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("bead.closed"),workflow:L.optional()}),B5=h({actor:o(),message:o().optional(),payload:gr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("bead.created"),workflow:L.optional()}),R5=h({actor:o(),message:o().optional(),payload:gr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("bead.updated"),workflow:L.optional()}),P5=h({actor:o(),message:o().optional(),payload:ni,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.created"),workflow:L.optional()}),N5=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.resumed"),workflow:L.optional()}),A5=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.suspended"),workflow:L.optional()}),O5=h({actor:o(),message:o().optional(),payload:ni,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.unregister_requested"),workflow:L.optional()}),j5=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("controller.started"),workflow:L.optional()}),$5=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("controller.stopped"),workflow:L.optional()}),L5=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("convoy.closed"),workflow:L.optional()}),D5=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("convoy.created"),workflow:L.optional()}),M5=h({actor:o(),message:o().optional(),payload:Jn(),run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:o(),workflow:L.optional()}),F5=h({actor:o(),message:o().optional(),payload:Tu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("events.rotated"),workflow:L.optional()}),U5=h({actor:o(),message:o().optional(),payload:ti,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.adapter_added"),workflow:L.optional()}),Z5=h({actor:o(),message:o().optional(),payload:ti,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.adapter_removed"),workflow:L.optional()}),q5=h({actor:o(),message:o().optional(),payload:yu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.bound"),workflow:L.optional()}),V5=h({actor:o(),message:o().optional(),payload:xu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.group_created"),workflow:L.optional()}),W5=h({actor:o(),message:o().optional(),payload:Eu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.inbound"),workflow:L.optional()}),H5=h({actor:o(),message:o().optional(),payload:Iu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.outbound"),workflow:L.optional()}),G5=h({actor:o(),message:o().optional(),payload:Mu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.unbound"),workflow:L.optional()}),J5=h({actor:o(),message:o().optional(),payload:Au,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("gc.store.maintenance.done"),workflow:L.optional()}),K5=h({actor:o(),message:o().optional(),payload:Ou,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("gc.store.maintenance.failed"),workflow:L.optional()}),Q5=h({actor:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.archived"),workflow:L.optional()}),Y5=h({actor:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.deleted"),workflow:L.optional()}),X5=h({actor:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.marked_read"),workflow:L.optional()}),ex=h({actor:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.marked_unread"),workflow:L.optional()}),tx=h({actor:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.read"),workflow:L.optional()}),nx=h({actor:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.replied"),workflow:L.optional()}),rx=h({actor:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.sent"),workflow:L.optional()}),ox=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("order.completed"),workflow:L.optional()}),ix=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("order.failed"),workflow:L.optional()}),ax=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("order.fired"),workflow:L.optional()}),sx=h({actor:o(),message:o().optional(),payload:bu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("pg.credential_resolved"),workflow:L.optional()}),lx=h({actor:o(),message:o().optional(),payload:zu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("project.identity.stamped"),workflow:L.optional()}),ux=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("provider.swapped"),workflow:L.optional()}),cx=h({actor:o(),message:o().optional(),payload:Cu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.failed"),workflow:L.optional()}),dx=h({actor:o(),message:o().optional(),payload:_u,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.city.create"),workflow:L.optional()}),px=h({actor:o(),message:o().optional(),payload:wu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.city.unregister"),workflow:L.optional()}),fx=h({actor:o(),message:o().optional(),payload:ju,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.session.create"),workflow:L.optional()}),mx=h({actor:o(),message:o().optional(),payload:Pu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.session.message"),workflow:L.optional()}),vx=h({actor:o(),message:o().optional(),payload:Nu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.session.submit"),workflow:L.optional()}),hx=h({actor:o(),message:o().optional(),payload:yr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.crashed"),workflow:L.optional()}),gx=h({actor:o(),message:o().optional(),payload:Ru,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.drain_acked_with_assigned_work"),workflow:L.optional()}),yx=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.draining"),workflow:L.optional()}),_x=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.idle_killed"),workflow:L.optional()}),wx=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.max_age_killed"),workflow:L.optional()}),xx=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.quarantined"),workflow:L.optional()}),Ex=h({actor:o(),message:o().optional(),payload:yr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.stopped"),workflow:L.optional()}),Ix=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.stranded"),workflow:L.optional()}),Sx=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.suspended"),workflow:L.optional()}),kx=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.undrained"),workflow:L.optional()}),bx=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.updated"),workflow:L.optional()}),zx=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.woke"),workflow:L.optional()}),Cx=h({actor:o(),message:o().optional(),payload:yr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.work_query_failed"),workflow:L.optional()}),Tx=h({actor:o(),message:o().optional(),payload:$u,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("supervisor.fs_pressure.skipped_tick"),workflow:L.optional()}),Bx=h({actor:o(),message:o().optional(),payload:Lu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("supervisor.shutdown_requested"),workflow:L.optional()}),Rx=h({actor:o(),message:o().optional(),payload:Fu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("worker.operation"),workflow:L.optional()}),cv=Hm("type",[T5.extend({type:x("bead.closed")}),B5.extend({type:x("bead.created")}),R5.extend({type:x("bead.updated")}),P5.extend({type:x("city.created")}),N5.extend({type:x("city.resumed")}),A5.extend({type:x("city.suspended")}),O5.extend({type:x("city.unregister_requested")}),j5.extend({type:x("controller.started")}),$5.extend({type:x("controller.stopped")}),L5.extend({type:x("convoy.closed")}),D5.extend({type:x("convoy.created")}),F5.extend({type:x("events.rotated")}),U5.extend({type:x("extmsg.adapter_added")}),Z5.extend({type:x("extmsg.adapter_removed")}),q5.extend({type:x("extmsg.bound")}),V5.extend({type:x("extmsg.group_created")}),W5.extend({type:x("extmsg.inbound")}),H5.extend({type:x("extmsg.outbound")}),G5.extend({type:x("extmsg.unbound")}),J5.extend({type:x("gc.store.maintenance.done")}),K5.extend({type:x("gc.store.maintenance.failed")}),Q5.extend({type:x("mail.archived")}),Y5.extend({type:x("mail.deleted")}),X5.extend({type:x("mail.marked_read")}),ex.extend({type:x("mail.marked_unread")}),tx.extend({type:x("mail.read")}),nx.extend({type:x("mail.replied")}),rx.extend({type:x("mail.sent")}),ox.extend({type:x("order.completed")}),ix.extend({type:x("order.failed")}),ax.extend({type:x("order.fired")}),sx.extend({type:x("pg.credential_resolved")}),lx.extend({type:x("project.identity.stamped")}),ux.extend({type:x("provider.swapped")}),cx.extend({type:x("request.failed")}),dx.extend({type:x("request.result.city.create")}),px.extend({type:x("request.result.city.unregister")}),fx.extend({type:x("request.result.session.create")}),mx.extend({type:x("request.result.session.message")}),vx.extend({type:x("request.result.session.submit")}),hx.extend({type:x("session.crashed")}),gx.extend({type:x("session.drain_acked_with_assigned_work")}),yx.extend({type:x("session.draining")}),_x.extend({type:x("session.idle_killed")}),wx.extend({type:x("session.max_age_killed")}),xx.extend({type:x("session.quarantined")}),Ex.extend({type:x("session.stopped")}),Ix.extend({type:x("session.stranded")}),Sx.extend({type:x("session.suspended")}),kx.extend({type:x("session.undrained")}),bx.extend({type:x("session.updated")}),zx.extend({type:x("session.woke")}),Cx.extend({type:x("session.work_query_failed")}),Tx.extend({type:x("supervisor.fs_pressure.skipped_tick")}),Bx.extend({type:x("supervisor.shutdown_requested")}),Rx.extend({type:x("worker.operation")}),M5.extend({type:x("TypedEventStreamEnvelopeCustom")})]);h({items:P(cv).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const Px=h({actor:o(),city:o(),message:o().optional(),payload:gr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("bead.closed"),workflow:L.optional()}),Nx=h({actor:o(),city:o(),message:o().optional(),payload:gr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("bead.created"),workflow:L.optional()}),Ax=h({actor:o(),city:o(),message:o().optional(),payload:gr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("bead.updated"),workflow:L.optional()}),Ox=h({actor:o(),city:o(),message:o().optional(),payload:ni,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.created"),workflow:L.optional()}),jx=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.resumed"),workflow:L.optional()}),$x=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.suspended"),workflow:L.optional()}),Lx=h({actor:o(),city:o(),message:o().optional(),payload:ni,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.unregister_requested"),workflow:L.optional()}),Dx=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("controller.started"),workflow:L.optional()}),Mx=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("controller.stopped"),workflow:L.optional()}),Fx=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("convoy.closed"),workflow:L.optional()}),Ux=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("convoy.created"),workflow:L.optional()}),Zx=h({actor:o(),city:o(),message:o().optional(),payload:Jn(),run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:o(),workflow:L.optional()}),qx=h({actor:o(),city:o(),message:o().optional(),payload:Tu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("events.rotated"),workflow:L.optional()}),Vx=h({actor:o(),city:o(),message:o().optional(),payload:ti,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.adapter_added"),workflow:L.optional()}),Wx=h({actor:o(),city:o(),message:o().optional(),payload:ti,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.adapter_removed"),workflow:L.optional()}),Hx=h({actor:o(),city:o(),message:o().optional(),payload:yu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.bound"),workflow:L.optional()}),Gx=h({actor:o(),city:o(),message:o().optional(),payload:xu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.group_created"),workflow:L.optional()}),Jx=h({actor:o(),city:o(),message:o().optional(),payload:Eu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.inbound"),workflow:L.optional()}),Kx=h({actor:o(),city:o(),message:o().optional(),payload:Iu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.outbound"),workflow:L.optional()}),Qx=h({actor:o(),city:o(),message:o().optional(),payload:Mu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.unbound"),workflow:L.optional()}),Yx=h({actor:o(),city:o(),message:o().optional(),payload:Au,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("gc.store.maintenance.done"),workflow:L.optional()}),Xx=h({actor:o(),city:o(),message:o().optional(),payload:Ou,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("gc.store.maintenance.failed"),workflow:L.optional()}),eE=h({actor:o(),city:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.archived"),workflow:L.optional()}),tE=h({actor:o(),city:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.deleted"),workflow:L.optional()}),nE=h({actor:o(),city:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.marked_read"),workflow:L.optional()}),rE=h({actor:o(),city:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.marked_unread"),workflow:L.optional()}),oE=h({actor:o(),city:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.read"),workflow:L.optional()}),iE=h({actor:o(),city:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.replied"),workflow:L.optional()}),aE=h({actor:o(),city:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.sent"),workflow:L.optional()}),sE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("order.completed"),workflow:L.optional()}),lE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("order.failed"),workflow:L.optional()}),uE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("order.fired"),workflow:L.optional()}),cE=h({actor:o(),city:o(),message:o().optional(),payload:bu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("pg.credential_resolved"),workflow:L.optional()}),dE=h({actor:o(),city:o(),message:o().optional(),payload:zu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("project.identity.stamped"),workflow:L.optional()}),pE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("provider.swapped"),workflow:L.optional()}),fE=h({actor:o(),city:o(),message:o().optional(),payload:Cu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.failed"),workflow:L.optional()}),mE=h({actor:o(),city:o(),message:o().optional(),payload:_u,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.city.create"),workflow:L.optional()}),vE=h({actor:o(),city:o(),message:o().optional(),payload:wu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.city.unregister"),workflow:L.optional()}),hE=h({actor:o(),city:o(),message:o().optional(),payload:ju,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.session.create"),workflow:L.optional()}),gE=h({actor:o(),city:o(),message:o().optional(),payload:Pu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.session.message"),workflow:L.optional()}),yE=h({actor:o(),city:o(),message:o().optional(),payload:Nu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.session.submit"),workflow:L.optional()}),_E=h({actor:o(),city:o(),message:o().optional(),payload:yr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.crashed"),workflow:L.optional()}),wE=h({actor:o(),city:o(),message:o().optional(),payload:Ru,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.drain_acked_with_assigned_work"),workflow:L.optional()}),xE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.draining"),workflow:L.optional()}),EE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.idle_killed"),workflow:L.optional()}),IE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.max_age_killed"),workflow:L.optional()}),SE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.quarantined"),workflow:L.optional()}),kE=h({actor:o(),city:o(),message:o().optional(),payload:yr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.stopped"),workflow:L.optional()}),bE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.stranded"),workflow:L.optional()}),zE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.suspended"),workflow:L.optional()}),CE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.undrained"),workflow:L.optional()}),TE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.updated"),workflow:L.optional()}),BE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.woke"),workflow:L.optional()}),RE=h({actor:o(),city:o(),message:o().optional(),payload:yr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.work_query_failed"),workflow:L.optional()}),PE=h({actor:o(),city:o(),message:o().optional(),payload:$u,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("supervisor.fs_pressure.skipped_tick"),workflow:L.optional()}),NE=h({actor:o(),city:o(),message:o().optional(),payload:Lu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("supervisor.shutdown_requested"),workflow:L.optional()}),AE=h({actor:o(),city:o(),message:o().optional(),payload:Fu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("worker.operation"),workflow:L.optional()}),dv=Hm("type",[Px.extend({type:x("bead.closed")}),Nx.extend({type:x("bead.created")}),Ax.extend({type:x("bead.updated")}),Ox.extend({type:x("city.created")}),jx.extend({type:x("city.resumed")}),$x.extend({type:x("city.suspended")}),Lx.extend({type:x("city.unregister_requested")}),Dx.extend({type:x("controller.started")}),Mx.extend({type:x("controller.stopped")}),Fx.extend({type:x("convoy.closed")}),Ux.extend({type:x("convoy.created")}),qx.extend({type:x("events.rotated")}),Vx.extend({type:x("extmsg.adapter_added")}),Wx.extend({type:x("extmsg.adapter_removed")}),Hx.extend({type:x("extmsg.bound")}),Gx.extend({type:x("extmsg.group_created")}),Jx.extend({type:x("extmsg.inbound")}),Kx.extend({type:x("extmsg.outbound")}),Qx.extend({type:x("extmsg.unbound")}),Yx.extend({type:x("gc.store.maintenance.done")}),Xx.extend({type:x("gc.store.maintenance.failed")}),eE.extend({type:x("mail.archived")}),tE.extend({type:x("mail.deleted")}),nE.extend({type:x("mail.marked_read")}),rE.extend({type:x("mail.marked_unread")}),oE.extend({type:x("mail.read")}),iE.extend({type:x("mail.replied")}),aE.extend({type:x("mail.sent")}),sE.extend({type:x("order.completed")}),lE.extend({type:x("order.failed")}),uE.extend({type:x("order.fired")}),cE.extend({type:x("pg.credential_resolved")}),dE.extend({type:x("project.identity.stamped")}),pE.extend({type:x("provider.swapped")}),fE.extend({type:x("request.failed")}),mE.extend({type:x("request.result.city.create")}),vE.extend({type:x("request.result.city.unregister")}),hE.extend({type:x("request.result.session.create")}),gE.extend({type:x("request.result.session.message")}),yE.extend({type:x("request.result.session.submit")}),_E.extend({type:x("session.crashed")}),wE.extend({type:x("session.drain_acked_with_assigned_work")}),xE.extend({type:x("session.draining")}),EE.extend({type:x("session.idle_killed")}),IE.extend({type:x("session.max_age_killed")}),SE.extend({type:x("session.quarantined")}),kE.extend({type:x("session.stopped")}),bE.extend({type:x("session.stranded")}),zE.extend({type:x("session.suspended")}),CE.extend({type:x("session.undrained")}),TE.extend({type:x("session.updated")}),BE.extend({type:x("session.woke")}),RE.extend({type:x("session.work_query_failed")}),PE.extend({type:x("supervisor.fs_pressure.skipped_tick")}),NE.extend({type:x("supervisor.shutdown_requested")}),AE.extend({type:x("worker.operation")}),Zx.extend({type:x("TypedTaggedEventStreamEnvelopeCustom")})]);h({event_cursor:o(),items:P(dv).nullable(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({beads:P(uv).nullable(),deps:P(eu).nullable(),logical_edges:P(eu).nullable(),logical_nodes:P(qw).nullable(),partial:Z(),resolved_root_store:o(),root_bead_id:o(),root_store_ref:o(),scope_groups:P(l5).nullable(),scope_kind:o(),scope_ref:o(),snapshot_event_seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),snapshot_version:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),stores_scanned:P(o()).nullable(),workflow_id:o()});const OE=h({declared_name:o().optional(),declared_prefix:o().optional(),name:o(),prefix:o().optional(),provider:o().optional(),session_template:o().optional(),suspended:Z()});h({agents:P(kw).nullable(),patches:zw.optional(),providers:fe(o(),r5).optional(),rigs:P(Cw).nullable(),workspace:OE});P(Yn([h({data:oo,event:x("heartbeat"),id:Be().optional(),retry:Be().optional()}),h({data:ov,event:x("turn"),id:Be().optional(),retry:Be().optional()})]));P(Yn([h({data:oo,event:x("heartbeat"),id:Be().optional(),retry:Be().optional()}),h({data:ov,event:x("turn"),id:Be().optional(),retry:Be().optional()})]));fe(o(),o());P(Yn([h({data:cv,event:x("event"),id:Be().optional(),retry:Be().optional()}),h({data:oo,event:x("heartbeat"),id:Be().optional(),retry:Be().optional()})]));P(Yn([h({data:iv,event:x("activity"),id:Be().optional(),retry:Be().optional()}),h({data:oo,event:x("heartbeat"),id:Be().optional(),retry:Be().optional()}),h({data:p5,event:x("message").optional(),id:Be().optional(),retry:Be().optional()}),h({data:ku,event:x("pending"),id:Be().optional(),retry:Be().optional()}),h({data:d5,event:x("turn"),id:Be().optional(),retry:Be().optional()})]));P(Yn([h({data:oo,event:x("heartbeat"),id:o().optional(),retry:Be().optional()}),h({data:dv,event:x("tagged_event"),id:o().optional(),retry:Be().optional()})]));class Wn extends Error{constructor(r,i,s){super(i),this.status=r,this.requestId=s}status;requestId;name="SupervisorApiError"}async function Ie(t,r){let i;try{i=await t}catch(p){throw jE(p)}const{response:s}=i;if(s===void 0)throw new Wn(void 0,tu(i.error),void 0);if(!s.ok||i.error!==void 0)throw new Wn(s.status,tu(i.error,s.statusText),s.headers.get("x-gc-request-id")??void 0);const u=i.data;if(u===void 0)throw new Wn(s.status,r,s.headers.get("x-gc-request-id")??void 0);return u}function jE(t){return t instanceof Wn?t:new Wn(void 0,tu(t),void 0)}function tu(t,r="gc supervisor request failed"){if(typeof t=="string"&&t.trim().length>0)return t.trim();if(t instanceof Error&&t.message.trim().length>0)return t.message.trim();if($E(t))for(const i of["error","message","detail"]){const s=t[i];if(typeof s=="string"&&s.trim().length>0)return s.trim()}return r}function $E(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)}const LE="";function DE(){const t=globalThis.location?.origin;return typeof t=="string"&&t.length>0&&t!=="null"?t:LE}function ME(t){if(!t.startsWith("/"))return t;const r=globalThis.location?.origin;return typeof r!="string"||r.length===0||r==="null"?t:new URL(t,r).toString().replace(/\/$/,"")}function Nf(t,r,i){const s=t.replace(/\/$/,""),u=new URLSearchParams(i).toString(),p=u.length>0?`${r}?${u}`:r;return s===""?p:s.startsWith("/")?`${s}${p}`:new URL(p,`${s}/`).toString()}const FE=6e4,Rt={"X-GC-Request":"dashboard"};let Af=null;const Of=new Map;function pv(t={}){const r=t.baseUrl??DE(),s={baseUrl:ME(r),headers:{Accept:"application/json"},responseStyle:"fields",throwOnError:!1},u=t.client??vm({...s,fetch:ZE(t.fetch??globalThis.fetch,fv(t.timeoutMs))});return{baseUrl:r,health(){return Ie(i7({client:u}),"gc supervisor health response was empty")},cityHealth(p){return Ie(w7({client:u,path:{cityName:p}}),"gc supervisor city health response was empty")},cityStatus(p){return Ie(A7({client:u,path:{cityName:p}}),"gc supervisor status response was empty")},listCities(){return Ie(a7({client:u}),"gc supervisor cities response was empty")},listAgents(p){return Ie(d7({client:u,path:{cityName:p}}),"gc supervisor agents response was empty")},listRigs(p){return Ie(C7({client:u,path:{cityName:p}}),"gc supervisor rigs response was empty")},listBeads(p,d){return Ie(v7({client:u,path:{cityName:p},...d===void 0?{}:{query:d}}),"gc supervisor beads response was empty")},listEvents(p,d){return Ie(g7({client:u,path:{cityName:p},...d===void 0?{}:{query:d}}),"gc supervisor events response was empty")},getBead(p,d){return Ie(p7({client:u,path:{cityName:p,id:d}}),"gc supervisor bead response was empty")},createBead(p,d){return Ie(h7({client:u,path:{cityName:p},headers:Rt,body:d}),"gc supervisor bead create response was empty")},updateBead(p,d,m){return Ie(f7({client:u,path:{cityName:p,id:d},headers:Rt,body:m}),"gc supervisor bead update response was empty")},closeBead(p,d,m){return Ie(m7({client:u,path:{cityName:p,id:d},headers:Rt,...m===void 0?{}:{body:m}}),"gc supervisor bead close response was empty")},nudgeAgent(p,d){const m=jf(d);return"dir"in m?Ie(c7({client:u,path:{cityName:p,dir:m.dir,base:m.base,action:"nudge"},headers:Rt}),"gc supervisor agent nudge response was empty"):Ie(l7({client:u,path:{cityName:p,base:m.base,action:"nudge"},headers:Rt}),"gc supervisor agent nudge response was empty")},agentPrime(p,d){const m=jf(d);return"dir"in m?Ie(u7({client:u,path:{cityName:p,dir:m.dir,base:m.base}}),"gc supervisor agent prime response was empty"):Ie(s7({client:u,path:{cityName:p,base:m.base}}),"gc supervisor agent prime response was empty")},sling(p,d){return Ie(N7({client:u,path:{cityName:p},headers:Rt,body:d}),"gc supervisor sling response was empty")},listMail(p,d){return Ie(x7({client:u,path:{cityName:p},...d===void 0?{}:{query:d}}),"gc supervisor mail response was empty")},formulaFeed(p,d){return Ie(y7({client:u,path:{cityName:p},...d===void 0?{}:{query:d}}),"gc supervisor formula feed response was empty")},sendMail(p,d){return Ie(E7({client:u,path:{cityName:p},headers:Rt,body:d}),"gc supervisor mail send response was empty")},mailThread(p,d){return Ie(I7({client:u,path:{cityName:p,id:d}}),"gc supervisor mail thread response was empty")},markMailRead(p,d,m){return Ie(b7({client:u,path:{cityName:p,id:d},headers:Rt,...m===void 0?{}:{query:m}}),"gc supervisor mail mark-read response was empty")},markMailUnread(p,d,m){return Ie(k7({client:u,path:{cityName:p,id:d},headers:Rt,...m===void 0?{}:{query:m}}),"gc supervisor mail mark-unread response was empty")},archiveMail(p,d,m){return Ie(S7({client:u,path:{cityName:p,id:d},headers:Rt,...m===void 0?{}:{query:m}}),"gc supervisor mail archive response was empty")},replyMail(p,d,m,g){return Ie(z7({client:u,path:{cityName:p,id:d},headers:Rt,body:m,...g===void 0?{}:{query:g}}),"gc supervisor mail reply response was empty")},cityEventStreamUrl(p,d){return Nf(r,`/v0/city/${encodeURIComponent(p)}/events/stream`,d===void 0?void 0:{after_seq:d})},sessionStreamUrl(p,d,m){return Nf(r,`/v0/city/${encodeURIComponent(p)}/session/${encodeURIComponent(d)}/stream`,m===void 0?void 0:{after:m})},listSessions(p){return Ie(P7({client:u,path:{cityName:p}}),"gc supervisor sessions response was empty")},sessionPending(p,d){return Ie(T7({client:u,path:{cityName:p,id:d}}),"gc supervisor session pending response was empty")},respondSession(p,d,m){return Ie(B7({client:u,path:{cityName:p,id:d},headers:Rt,body:m}),"gc supervisor session respond response was empty")},sessionTranscript(p,d){return Ie(R7({client:u,path:{cityName:p,id:d},query:{format:"conversation"}}),"gc supervisor transcript response was empty")},workflowRun(p,d,m){return Ie(O7({client:u,path:{cityName:p,workflow_id:d},...m===void 0?{}:{query:m}}),"gc supervisor workflow response was empty")},formulaDetail(p,d,m){return Ie(_7({client:u,path:{cityName:p,name:d},query:m}),"gc supervisor formula detail response was empty")},mutationHeaders(){return{...Rt}}}}function ot(){return Af??=pv(),Af}function UE(t){const r=fv(t),i=Of.get(r);if(i!==void 0)return i;const s=pv({timeoutMs:r});return Of.set(r,s),s}function jf(t){const r=t.trim().split("/");if(r.length===1){const i=r[0];if(i!==void 0&&i!=="")return{base:i}}if(r.length===2){const i=r[0],s=r[1];if(i!==void 0&&i!==""&&s!==void 0&&s!=="")return{dir:i,base:s}}throw new Error(`invalid agent alias: ${t}`)}function fv(t){return typeof t=="number"&&Number.isFinite(t)&&t>0?t:FE}function ZE(t,r){return async(i,s)=>{const u=new AbortController,p=new Wn(void 0,`gc supervisor request timed out after ${r}ms`,void 0),d=qE(i,s);d?.aborted&&u.abort(d.reason);const m=()=>u.abort(d?.reason);d?.addEventListener("abort",m,{once:!0});let g;const y=new Promise((T,A)=>{g=setTimeout(()=>{u.abort(p),A(p)},r)}),E=new Request(i,{...s,signal:u.signal}),S=t(E);try{return await Promise.race([S,y])}finally{g!==void 0&&clearTimeout(g),d?.removeEventListener("abort",m)}}}function qE(t,r){return r?.signal!==void 0?r.signal:t instanceof Request?t.signal:null}async function VE(t,r){const i=xn("list agent pending interactions"),s=WE(r),u=t.flatMap(d=>{const m=d.session?.name;if(m===void 0)return[];const g=s.get(m);return g===void 0?[]:[{agentName:d.name,sessionId:g,sessionName:m}]});return(await Promise.all(u.map(async d=>{const m=await ot().sessionPending(i,d.sessionId);return m.pending===void 0?null:{...d,pending:m.pending}}))).filter(d=>d!==null)}async function j6(t,r){const i=xn("respond to agent pending interaction");return ot().respondSession(i,t,r)}function $6(t){return`gc agent attach ${HE(t)}`}function WE(t){const r=new Map;for(const i of t)i.session_name!==void 0&&r.set(i.session_name,i.id);return r}function HE(t){return/^[A-Za-z0-9_./:-]+$/.test(t)?t:`'${t.replaceAll("'","'\\''")}'`}const GE=1e3,JE=200,KE=1e3,QE=new Set(["feature","bug","task","epic","chore","decision"]);async function YE(t={}){const r=xn("list supervisor beads"),i=t.limit??GE,s=t.rigFilter?.trim()??"",u=t.includeClosed??!1,p=t.includeBookkeeping??!1,d={limit:i,...u?{all:!0}:{},...s.length===0?{}:{rig:s}},m=await ot().listBeads(r,d),g=vv(m.items??[]),y=u?g:g.filter(T=>T.status!=="closed"),E=p?y:y.filter(XE),S=mv(m.total);return{items:E,total:E.length,...S===void 0?{}:{upstream_total:S},upstream_fetched:g.length,fetch_limit:i}}async function L6(t,r={}){const i=xn("list supervisor assigned beads"),s=t4(t),u=r.limit??JE,p=r.includeClosed??!1;if(s.length===0)return{items:[],total:0,upstream_fetched:0,fetch_limit:u};const d=await Promise.all(s.map(y=>ot().listBeads(i,{assignee:y,limit:u,...p?{all:!0}:{}}))),m=vv(d.flatMap(y=>y.items??[])),g=e4(d);return{items:m,total:m.length,...g===void 0?{}:{upstream_total:g},upstream_fetched:m.length,fetch_limit:u}}async function D6(t){const r=xn("fetch supervisor bead");try{return await ot().getBead(r,t)}catch(i){if(!(i instanceof Wn)||i.status!==404)throw i;const u=((await ot().listBeads(r,{limit:KE})).items??[]).find(p=>p.id===t);if(u!==void 0)return u;throw i}}function XE(t){return!(!QE.has(t.issue_type)||Array.isArray(t.labels)&&t.labels.some(r=>r.startsWith("gc:")))}function mv(t){if(typeof t=="number")return t;if(typeof t=="bigint")return Number(t)}function e4(t){let r=0;for(const i of t){const s=mv(i.total);if(s===void 0)return;r+=s}return r}function vv(t){const r=new Set,i=[];for(const s of t)r.has(s.id)||(r.add(s.id),i.push(s));return i}function t4(t){const r=new Set,i=[];for(const s of t){const u=s.trim();u.length===0||r.has(u)||(r.add(u),i.push(u))}return i}const M6=[100,500,1e3],Uu=100,F6=["24h","7d","all"],n4="all",r4={"24h":1440*60*1e3,"7d":10080*60*1e3};async function Zu(t,r,i,s=Uu,u=n4,p=Date.now()){const d=xn("list supervisor mail"),m=await ot().listMail(d,{limit:s}),g=m.items??[],y=i4(o4(g,t,r,i),u,p);return y.sort(l4),{...m,items:y,total:y.length,upstream_total:g.length,upstream_fetched:g.length,fetch_limit:s}}async function U6(t,r,i,s=Uu){const u=xn("fetch supervisor mail thread");try{const p=await ot().mailThread(u,t);return $f(p)}catch(p){if(!(p instanceof Wn)||p.status!==404)throw p;const d=await Zu("all",r,i,s),m=d.items.filter(g=>g.thread_id===t);return $f({...d,items:m,total:m.length})}}function $f(t){const r=s4(t.items??[]).sort(u4);return{...t,items:r,total:r.length}}function o4(t,r,i,s){const u=a4(i,s);return r==="all"?[...t]:r==="inbox"?t.filter(p=>p.to.toLowerCase()===u):t.filter(p=>p.from.toLowerCase()===u)}function i4(t,r,i){if(r==="all")return[...t];const s=i-r4[r];return t.filter(u=>{const p=Date.parse(u.created_at);return Number.isFinite(p)&&p>=s})}function a4(t,r){const i=t.toLowerCase();return i===r.operatorAlias.toLowerCase()?r.operatorWireAlias:i}function s4(t){const r=new Set,i=[];for(const s of t)r.has(s.id)||(r.add(s.id),i.push(s));return i}function l4(t,r){return r.created_at.localeCompare(t.created_at)}function u4(t,r){return t.created_at.localeCompare(r.created_at)}function hv(t,r){if(t===void 0||t.length===0)return null;const i=Date.parse(t);if(!Number.isFinite(i))return null;const s=r-i;return s>=0?s:null}function gv(t){const r=Math.max(1,Math.round(t/36e5));return r<48?`${r}h`:`${Math.round(r/24)}d`}const c4=1440*60*1e3,d4=4320*60*1e3;function p4(t,r){const i=[];for(const s of t.escalations){const u=f4(s);u!==null&&i.push(u)}for(const s of t.beads){const u=m4(s,r);u!==null&&i.push(u)}return i}function f4(t){return t.status==="closed"?null:{beadId:t.id,reason:"escalated",severity:"attention",summary:`${t.title} — escalation raised`,updatedAt:t.updated_at??t.created_at}}function m4(t,r){if(t.status!=="open"||v4(t))return null;const i=hv(t.created_at,r);if(i===null||i=d4;return{beadId:t.id,reason:"ready-unclaimed",severity:s?"attention":"watch",summary:`${t.title} opened ${gv(i)} ago`,updatedAt:t.created_at}}function v4(t){return t.assignee!==void 0&&t.assignee.trim().length>0}function Lf(t,r){const i=`/runs/${encodeURIComponent(t)}`;if(r.status!=="available")return i;const s=new URLSearchParams;return s.set("scope_kind",r.kind),s.set("scope_ref",r.ref),`${i}?${s.toString()}`}const h4={"awaiting-input":"awaiting input",errored:"errored","rate-limited":"rate limited",stalled:"stalled"},g4={respond:"Respond to its prompt.",reset:"Reset the agent.",nudge:"Nudge it to resume."},y4={"awaiting-input":"stuck",errored:"stuck","rate-limited":"warn",stalled:"warn"};function _4(t){return h4[t]}function Z6(t){return g4[t]}function q6(t){return y4[t]}const w4=new Set(["gc.store.maintenance.failed","order.failed","request.failed","session.crashed","session.stranded","session.work_query_failed","supervisor.shutdown_requested"]),x4=new Set(["events.rotated","session.quarantined","session.suspended","supervisor.fs_pressure.skipped_tick"]);function E4(t){return w4.has(t.type)?"attention":x4.has(t.type)?"watch":"event"}function I4(t){return t.message??t.subject??t.type}const S4=1440*60*1e3,k4=30,b4=2e9,z4=1e9,C4=1e9,T4=512e6,B4="gc:escalation",R4="decision.decide";function P4(t={}){return Xo.map(r=>N4(r,t))}function N4(t,r){switch(t){case"activity":return D4(r.activity);case"agents":return j4(r.agents);case"beads":return $4(r.beads);case"health":return A4(r.health);case"mail":return L4(r.mail);case"runs":return O4(r.runs)}}function A4(t){return{id:"health:derived",domain:"health",getItems:()=>Q4(t)}}function O4(t){return{id:"runs:derived",domain:"runs",getItems:()=>M4(t)}}function j4(t){return{id:"agents:derived",domain:"agents",getItems:()=>F4(t)}}function $4(t){return{id:"beads:derived",domain:"beads",getItems:()=>U4(t)}}function L4(t){return{id:"mail:derived",domain:"mail",getItems:()=>W4(t)}}function D4(t){return{id:"activity:derived",domain:"activity",getItems:()=>G4(t)}}function M4(t){const r=[];if(t===void 0)return r;const i={provenance:t.provenance,fetchedAt:t.fetchedAt};if(t.error!==void 0&&t.error.length>0)return r.push(It("runs",{id:"runs:unavailable",title:"Run data unavailable",summary:t.error,href:"/runs"})),r;const s=t.summary;if(s===void 0)return r;s.lanesPartial===!0&&r.push(Ho("runs",{id:"runs:partial",title:"Run list incomplete",href:"/runs"},i));for(const u of[...s.lanes,...s.blockedLanes])u.health.status!=="available"&&r.push(Ho("runs",{id:`runs:${u.id}:health-unavailable`,title:`${u.title} health unavailable`,summary:u.health.error,href:Lf(u.id,u.scope)},i));for(const u of oy(s.blockedLanes))r.push(It("runs",{id:`runs:${u.id}:blocked`,title:`${u.title} blocked`,summary:u.reason,href:Lf(u.id,u.scope)}));return r}function F4(t){const r=[];if(t===void 0)return r;if(t.error!==void 0&&t.error.length>0)return r.push(Ho("agents",{id:"agents:unavailable",title:"Agent data unavailable",summary:t.error,href:"/agents"})),r;t.partial===!0&&r.push(Ho("agents",{id:"agents:partial",title:"Agent list incomplete",href:"/agents"})),t.pendingError!==void 0&&t.pendingError.length>0&&r.push(Ho("agents",{id:"agents:pending-unavailable",title:"Agent pending state unavailable",summary:t.pendingError,href:"/agents"}));const i=(t.pendingInteractions??[]).map(s=>({agentName:s.agentName,...s.pending.prompt===void 0?{}:{prompt:s.pending.prompt}}));for(const s of X0(t.items??[],i))r.push(It("agents",{id:`agents:${s.name}:needs-you`,title:`${s.name} ${_4(s.reason)}`,summary:s.detail,href:`/agents/${encodeURIComponent(s.name)}`}));return r}function U4(t){const r=[];if(t===void 0)return r;t.error!==void 0&&t.error.length>0&&r.push(It("beads",{id:"beads:unavailable",title:"Bead data unavailable",summary:t.error,href:"/beads"})),t.partial===!0&&r.push(qn("beads",{id:"beads:partial",title:"Bead list incomplete",href:"/beads"})),t.decisionsError!==void 0&&t.decisionsError.length>0&&r.push(It("beads",{id:"beads:decisions-unavailable",title:"Decision queue unavailable",summary:t.decisionsError,href:"/beads"})),t.escalationsError!==void 0&&t.escalationsError.length>0&&r.push(It("beads",{id:"beads:escalations-unavailable",title:"Escalation queue unavailable",summary:t.escalationsError,href:"/beads"}));for(const u of t.decisions??[])r.push(V4(u));const i=t.nowMs??Date.now(),s=(t.items??[]).filter(u=>!q4(u,t.decisionLabel));for(const u of p4({beads:s,escalations:t.escalations??[]},i)){const p=u.severity==="attention"?It:qn;r.push(p("beads",{id:`beads:${u.beadId}:${u.reason}`,title:`${u.beadId} ${Z4(u.reason)}`,summary:u.summary,href:yv(u.beadId),updatedAt:u.updatedAt}))}return r}function Z4(t){return t==="escalated"?"escalated":"unclaimed"}function yv(t){const r=new URLSearchParams;return r.set("bead",t),`/beads?${r.toString()}`}function q4(t,r){return(t.labels??[]).includes(r)}function V4(t){const r=t.metadata?.[R4];return It("beads",{id:`beads:${t.id}:mayor-decision`,title:t.title,href:yv(t.id),updatedAt:t.updated_at??t.created_at,...r!==void 0&&r.trim().length>0?{summary:r}:{}})}function W4(t){const r=[];if(t===void 0)return r;t.error!==void 0&&t.error.length>0&&r.push(It("mail",{id:"mail:unavailable",title:"Mail data unavailable",summary:t.error,href:"/mail"})),t.partial===!0&&r.push(qn("mail",{id:"mail:partial",title:"Mail list incomplete",href:"/mail"}));const i=t.nowMs??Date.now();for(const s of py(t.items??[])){const u=hv(s.created_at,i),p=u!==null&&u>=S4;r.push(It("mail",{id:`mail:${s.id}:${p?"unread-stale":"unread"}`,title:s.subject,summary:p?`from ${s.from}, unread for ${gv(u)}`:`from ${s.from}`,href:H4(s.id),updatedAt:s.created_at}))}return r}function H4(t){const r=new URLSearchParams;return r.set("message",t),`/mail?${r.toString()}`}function G4(t){const r=[];if(t===void 0)return r;t.deploysError!==void 0&&t.deploysError.length>0&&r.push(It("activity",{id:"activity:deploys-unavailable",title:"Deploy data unavailable",summary:t.deploysError,href:"/activity"})),t.eventsDegraded!==void 0&&t.eventsDegraded.length>0&&r.push(qn("activity",{id:"activity:events-degraded",title:"Event stream degraded",summary:t.eventsDegraded,href:"/activity"})),t.eventsError!==void 0&&t.eventsError.length>0&&r.push(qn("activity",{id:"activity:events-unavailable",title:"Event history unavailable",summary:t.eventsError,href:"/activity"})),t.eventsPartial===!0&&r.push(qn("activity",{id:"activity:events-partial",title:"Event history incomplete",href:"/activity"})),J4(r,t.events??[]);const i=t.deploys;if(i===void 0)return r;i.failed_marker&&r.push(It("activity",{id:"activity:failed-marker",title:"Deploy failed marker present",href:"/activity"}));for(const s of i.items)s.status==="failed"?r.push(It("activity",{id:`activity:deploy:${s.at}:failed`,title:"Deploy failed",summary:s.detail,href:"/activity",updatedAt:s.at})):s.status==="in-progress"&&r.push(qn("activity",{id:`activity:deploy:${s.at}:in-progress`,title:"Deploy in progress",summary:s.detail,href:"/activity",updatedAt:s.at}));return r}function J4(t,r){for(const i of r){const s=E4(i);if(s==="event")continue;const u=s==="attention"?It:qn;t.push(u("activity",{id:`activity:event:${String(i.seq)}:${i.type}`,title:i.type,summary:I4(i),href:K4(i),updatedAt:i.ts}))}}function K4(t){return`/activity?${new URLSearchParams({mode:"events",type:t.type}).toString()}`}function Q4(t){const r=[];return t===void 0||(t.dashboardError!==void 0&&t.dashboardError.length>0&&r.push(Hn({id:"health:dashboard-health-unavailable",title:"Dashboard health unavailable",summary:t.dashboardError})),t.supervisor!==void 0&&Y4(r,t.supervisor),t.system!==void 0&&(X4(r,t.system),eI(r,t.system)),t.trend!==void 0&&!t.trend.available&&r.push(mr({id:"health:dolt-noms-unavailable",title:"Dolt-noms trend unavailable",summary:t.trend.reason}))),r}function Y4(t,r){if(r.status==="unavailable"){t.push(Hn({id:"health:supervisor-unreachable",title:"Supervisor unreachable",summary:r.error}));return}const i=r.data;i.status!=="ok"&&t.push(Hn({id:"health:supervisor-not-ok",title:`Supervisor ${i.status}`})),i.city===void 0&&t.push(mr({id:"health:supervisor-city-missing",title:"Supervisor city missing",summary:"city was absent from generated supervisor health"})),i.version===void 0&&t.push(mr({id:"health:supervisor-version-missing",title:"Supervisor version missing",summary:"version was absent from generated supervisor health"}))}function X4(t,r){const i=r.admin;i.uptime_sec=b4?t.push(Hn({id:"health:dashboard-process-rss-high",title:"Dashboard RSS high",summary:_a(i.rss_bytes)})):i.rss_bytes>=z4&&t.push(mr({id:"health:dashboard-process-rss-elevated",title:"Dashboard RSS elevated",summary:_a(i.rss_bytes)})),i.heap_used_bytes>=C4?t.push(Hn({id:"health:dashboard-process-heap-high",title:"Dashboard heap high",summary:_a(i.heap_used_bytes)})):i.heap_used_bytes>=T4&&t.push(mr({id:"health:dashboard-process-heap-elevated",title:"Dashboard heap elevated",summary:_a(i.heap_used_bytes)}))}function eI(t,r){const i=Df(r.host.free_mem_bytes,r.host.total_mem_bytes);i!==null&&i<.05?t.push(Hn({id:"health:memory-critical",title:"Host memory critical",summary:`${Math.round(i*100)}% free`})):i!==null&&i<.1&&t.push(mr({id:"health:memory-low",title:"Host memory low",summary:`${Math.round(i*100)}% free`}));const s=Df(r.host.load_avg_1,r.host.cpu_count);s!==null&&s>1.5?t.push(Hn({id:"health:load-high",title:"Host load high",summary:`${r.host.load_avg_1.toFixed(2)} load across ${r.host.cpu_count} CPUs`})):s!==null&&s>1&&t.push(mr({id:"health:load-elevated",title:"Host load elevated",summary:`${r.host.load_avg_1.toFixed(2)} load across ${r.host.cpu_count} CPUs`}))}function _a(t){return t>=1e9?`${(t/1e9).toFixed(1)} GB`:t>=1e6?`${Math.round(t/1e6)} MB`:t>=1e3?`${Math.round(t/1e3)} KB`:`${t} B`}function Df(t,r){return r<=0?null:t/r}function Hn(t){return{domain:"health",severity:"attention",href:"/health",current:!0,actionable:!0,...t}}function It(t,r){return{domain:t,severity:"attention",current:!0,actionable:!0,...r}}function qn(t,r){return{domain:t,severity:"watch",current:!0,actionable:!1,...r}}function Ho(t,r,i){return{domain:t,severity:"unavailable",current:!0,actionable:!1,...r,...i?.provenance===void 0?{}:{provenance:i.provenance},...i?.fetchedAt===void 0?{}:{fetchedAt:i.fetchedAt}}}function mr(t){return{domain:"health",severity:"watch",href:"/health",current:!0,actionable:!1,...t}}const tI=1e3,nI=100,rI="24h",oI=2500;function iI(t,r){const i=Ba(),s=i??"no-city",{decisionLabel:u,operatorWireAlias:p}=t,d=b.useMemo(()=>aI(r),[r]),m=mn(`attention:agents:${s}`,()=>sI(i)),g=mn(`attention:beads:${s}:${u}`,()=>lI(i,u)),y=mn(`attention:mail:${s}:${p}`,()=>dI(i,t)),E=mn(`attention:activity:${s}`,()=>pI(i)),S=mn(`attention:health:${s}`,()=>fI(i));return b.useMemo(()=>P4(mI({activity:E.data,agents:m.data,beads:g.data,health:S.data,mail:y.data,runs:d})),[E.data,m.data,g.data,S.data,y.data,d])}function aI(t){if(t!==void 0)return t.status==="error"?{error:t.error,provenance:"error"}:{summary:t.data,provenance:t.status,fetchedAt:t.fetchedAt}}async function sI(t){if(t===null)return{};try{const r=await ot().listAgents(t),i={items:r.items??[],partial:r.partial===!0};try{const s=await ot().listSessions(t);i.pendingInteractions=await VE(r.items??[],s.items??[])}catch(s){i.pendingError=Jt(s,"agent pending state unavailable")}return i}catch(r){return{error:Jt(r,"agent list unavailable")}}}async function lI(t,r){if(t===null)return{decisionLabel:r};const[i,s,u]=await Promise.allSettled([YE({limit:tI}),uI(t,r),cI(t)]),p={nowMs:Date.now(),decisionLabel:r};return i.status==="fulfilled"?(p.items=i.value.items,p.partial=i.value.partial===!0):p.error=Jt(i.reason,"bead list unavailable"),s.status==="fulfilled"?p.decisions=s.value.items??[]:p.decisionsError=Jt(s.reason,"decision queue unavailable"),u.status==="fulfilled"?p.escalations=u.value.items??[]:p.escalationsError=Jt(u.reason,"escalation queue unavailable"),p}async function uI(t,r){return ot().listBeads(t,{label:r,status:"open"})}async function cI(t){return ot().listBeads(t,{label:B4,status:"open"})}async function dI(t,r){if(t===null)return{};try{const i=await Zu("inbox",r.operatorAlias,r,Uu);return{items:i.items??[],nowMs:Date.now(),partial:i.partial===!0}}catch(i){return{error:Jt(i,"mail list unavailable")}}}async function pI(t){const[r,i]=await Promise.allSettled([Yr.listBuilds(),t===null?Promise.resolve(null):ot().listEvents(t,{limit:nI,since:rI})]),s={};return r.status==="fulfilled"?s.deploys=r.value:s.deploysError=Jt(r.reason,"deploy activity unavailable"),i.status==="fulfilled"?i.value!==null&&(s.events=i.value.items??[],s.eventsPartial=i.value.partial===!0,i.value.partial_errors!==null&&i.value.partial_errors!==void 0&&(s.eventsDegraded=i.value.partial_errors.join("; "))):s.eventsError=Jt(i.reason,"event history unavailable"),s}async function fI(t){if(t===null)return{};const[r,i,s]=await Promise.allSettled([Yr.systemHealth(),UE(oI).cityHealth(t),Yr.doltTrend()]),u={},p=[];return r.status==="fulfilled"?u.system=r.value:p.push(Jt(r.reason,"dashboard health unavailable")),i.status==="fulfilled"?u.supervisor={status:"available",data:i.value}:u.supervisor={status:"unavailable",error:Jt(i.reason,"supervisor health unavailable")},s.status==="fulfilled"?u.trend=s.value:p.push(Jt(s.reason,"dolt-noms trend unavailable")),p.length>0&&(u.dashboardError=p.join("; ")),u}function mI(t){const r={};for(const[i,s]of Object.entries(t))s!==void 0&&(r[i]=s);return r}async function Jr(t){const r={Accept:"application/json","Content-Type":"application/json","X-GC-Request":"dashboard"};try{const i=await fetch("/api/client-errors",{method:"POST",headers:r,credentials:"same-origin",keepalive:!0,body:JSON.stringify(t)});return i.ok?{status:"reported"}:{status:"failed",error:`client error report failed with ${i.status}`}}catch(i){return{status:"failed",error:Wr(i)}}}class _v extends b.Component{state={crashed:!1};static getDerivedStateFromError(){return{crashed:!0}}componentDidCatch(r,i){Jr({component:"ErrorBoundary",operation:"componentDidCatch",message:Wr(r)})}render(){return this.state.crashed?$.jsx("main",{className:"max-w-dashboard mx-auto px-4 sm:px-6 lg:px-8 py-12",children:$.jsxs("section",{className:"space-y-4",role:"alert",children:[$.jsx("h1",{className:"text-display font-semibold text-fg",children:"Dashboard view failed."}),$.jsx("p",{className:"text-body text-fg-muted",children:"The error was reported to the local dashboard log. Refresh to retry this view."})]})}):this.props.children}}function vI({label:t,summary:r}){const i=r.attention+r.watch;if(i===0||r.severity===null)return null;const s=i===1?"item":"items";return $.jsx("span",{"aria-label":`${t}: ${i} ${r.severity} ${s}`,className:`ml-1 align-super text-[0.65rem] leading-none tnum ${hI(r.severity)}`,children:i})}function hI(t){return t==="attention"?"text-accent":"text-warn"}function wv(t,r,i){try{const s=qu(t).getItem(r);return s===null?{status:"missing"}:{status:"found",value:s}}catch(s){return Vu(t,"getItem",r,i,s)}}function xv(t,r,i,s){try{return qu(t).setItem(r,i),{status:"stored"}}catch(u){return Vu(t,"setItem",r,s,u)}}function Ev(t,r,i){try{return qu(t).removeItem(r),{status:"stored"}}catch(s){return Vu(t,"removeItem",r,i,s)}}function qu(t){return t==="localStorage"?window.localStorage:window.sessionStorage}function Vu(t,r,i,s,u){const p=Wr(u);return Jr({component:s,operation:`${t}.${r}`,message:`${i}: ${p}`}),{status:"unavailable",error:p}}const nu="gascity:theme",ru="ThemeContext",Iv=b.createContext(null);function gI(){const t=wv("localStorage",nu,ru);return t.status==="found"&&(t.value==="light"||t.value==="dark")?t.value:"system"}function yI(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function _I(t){const r=document.documentElement;t==="system"?r.removeAttribute("data-theme"):r.setAttribute("data-theme",t)}function wI({children:t}){const[r,i]=b.useState(gI),[s,u]=b.useState(yI);b.useEffect(()=>{const y=window.matchMedia("(prefers-color-scheme: dark)"),E=()=>u(y.matches?"dark":"light");return y.addEventListener("change",E),()=>y.removeEventListener("change",E)},[]);const p=r==="system"?s:r,d=b.useCallback(y=>{i(y),y==="system"?Ev("localStorage",nu,ru):xv("localStorage",nu,y,ru),_I(y)},[]),m=b.useCallback(()=>{d(p==="dark"?"light":"dark")},[p,d]),g=b.useMemo(()=>({pref:r,resolved:p,set:d,toggle:m}),[r,p,d,m]);return $.jsx(Iv.Provider,{value:g,children:t})}function xI(){const t=b.useContext(Iv);if(t===null)throw new Error("useTheme must be used inside ");return t}const Sv={operatorAlias:"operator",operatorWireAlias:"human",decisionLabel:"needs/operator"},kv=b.createContext(Sv);function EI({operator:t,children:r}){return $.jsx(kv.Provider,{value:t,children:r})}function bv(){return b.useContext(kv)}function II(t){return t===void 0?Sv:{operatorAlias:t.operatorAlias,operatorWireAlias:t.operatorWireAlias,decisionLabel:t.decisionLabel}}const SI={ok:"text-ok",warn:"text-warn",stuck:"text-accent",neutral:"text-fg-muted"},kI={ok:"●",warn:"▲",stuck:"■",neutral:"·"};function bI({tone:t,label:r,glyph:i,trailing:s,className:u="",title:p}){return $.jsxs("span",{className:`inline-flex items-baseline gap-1.5 text-body ${SI[t]} ${u}`,title:p,children:[$.jsx("span",{"aria-hidden":!0,className:"text-[0.85em] leading-none translate-y-[1px]",children:i??kI[t]}),$.jsx("span",{children:r}),s&&$.jsx("span",{className:"text-fg-faint text-label uppercase tracking-wider",children:s})]})}function V6(t){switch(t){case"closed":return"neutral";case"in_progress":return"ok";case"blocked":return"stuck";default:return"warn"}}function W6(t){switch(t){case"active":case"running":return"ok";case"rate-limited":case"rate_limited":case"waiting":return"warn";case"failed":case"closed":case"errored":case"stuck":return"stuck";default:return"neutral"}}const zv=b.createContext(!1);function zI({readOnly:t,children:r}){return $.jsx(zv.Provider,{value:t,children:r})}function CI(){return b.useContext(zv)}function TI(t,r){return t?t.readOnly:r!==null}const Cv="Read-only mode: mutations are disabled";function H6(){return $.jsx(bI,{tone:"warn",label:"Read-only",title:Cv})}const BI="mayor";function RI(t){const{operator:r,sessionAliases:i,mailFromOrTo:s}=t,u=new Map;for(const A of i){const D=A.toLowerCase();u.has(D)||u.set(D,A)}for(const A of s){const D=A.toLowerCase();u.has(D)||u.set(D,A)}const p=r.toLowerCase(),d=new Set(s.map(A=>A.toLowerCase())),m=[r],g=[],y=[],E=[];for(const[A,D]of u)if(A!==p){if(A===BI){g.push(D);continue}d.has(A)?y.push(D):E.push(D)}const S=(A,D)=>A.toLowerCase().localeCompare(D.toLowerCase());y.sort(S),E.sort(S);const T=[{tier:"you",aliases:m}];return g.length>0&&T.push({tier:"mayor",aliases:g}),y.length>0&&T.push({tier:"active",aliases:y}),E.length>0&&T.push({tier:"other",aliases:E}),T}function PI(t,r){return t===r?"user":t}function G6(t){switch(t){case"you":return"You";case"mayor":return"Mayor";case"active":return"Active";case"other":return"Other"}}async function NI(){return ot().listSessions(xn("list supervisor sessions"))}async function J6(t){const r=await ot().sessionTranscript(xn("fetch supervisor session transcript"),t);return OI(r)}function K6(t){return(t.items??[]).map(AI)}function AI(t){const r={id:t.id,template:t.template,session_name:t.session_name,title:t.title,state:t.state,created_at:t.created_at,attached:t.attached,running:t.running,provider:t.provider};return t.alias!==void 0&&(r.alias=t.alias),t.reason!==void 0&&(r.reason=t.reason),t.display_name!==void 0&&(r.display_name=t.display_name),t.last_active!==void 0&&(r.last_active=t.last_active),t.rig!==void 0&&(r.rig=t.rig),t.pool!==void 0&&(r.pool=t.pool),t.agent_kind!==void 0&&(r.agent_kind=t.agent_kind),t.model!==void 0&&(r.model=t.model),t.context_pct!==void 0&&(r.context_pct=t.context_pct),t.context_window!==void 0&&(r.context_window=t.context_window),t.activity!==void 0&&(r.activity=t.activity),r}function OI(t,r=new Date().toISOString()){const i=t.turns??[];return{...t,turns:i,total_chars:i.reduce((s,u)=>s+u.text.length,0),captured_at:r,truncated:!1}}const ou="gascity.dashboard.viewingAs",Kr="ViewingAsContext",Mf=/^[a-z][a-z0-9_./-]{1,63}$/i,Ff=[3e4,9e4,27e4];function jI(t){if(!Number.isInteger(t)||t<0||t>=Ff.length)return null;const r=Ff[t];return r===void 0?null:r}const Tv=b.createContext(null);function Uf(t){const r=wv("sessionStorage",ou,Kr);if(r.status==="found"){const i=r.value;if(i.length>0&&i.length<=64)return i}return t}function Zl(t,r){t===r?Ev("sessionStorage",ou,Kr):xv("sessionStorage",ou,t,Kr)}function $I({children:t}){const r=bv(),{operatorAlias:i}=r,[s,u]=b.useState(()=>Uf(i)),p=b.useRef(i),[d,m]=b.useState([]),[g,y]=b.useState([]),[E,S]=b.useState(!1),[T,A]=b.useState(!1),D=b.useRef(!1),W=b.useRef(!0),O=b.useRef(null),H=b.useCallback(pe=>{u(pe),Zl(pe,i)},[i]),oe=b.useCallback(()=>{u(i),Zl(i,i)},[i]),Q=b.useCallback(async()=>{try{const pe=await NI();if(!W.current)return!0;const Re=new Set,ye=[];for(const Ze of pe.items??[]){if(typeof Ze.alias!="string"||!Mf.test(Ze.alias))continue;const Ke=Ze.alias.toLowerCase();Re.has(Ke)||(Re.add(Ke),ye.push(Ze.alias))}return m(ye),A(!1),!0}catch(pe){return Jr({component:Kr,operation:"loadAliases.sessions",message:Wr(pe)}),!1}},[]),G=b.useCallback(pe=>{if(!W.current)return;const Re=jI(pe);Re!==null&&(O.current=setTimeout(()=>{O.current=null,W.current&&Q().then(ye=>{W.current&&(ye||G(pe+1))}).catch(ye=>{Jr({component:Kr,operation:"loadAliases.sessionsRetry",message:Wr(ye)})})},Re))},[Q]),ee=b.useCallback(()=>{if(D.current)return;D.current=!0,S(!0);let pe=2;const Re=()=>{pe-=1,pe===0&&W.current&&S(!1)};Q().then(ye=>{W.current&&(ye||(A(!0),G(0)))}).finally(Re),Zu("all",i,r).then(ye=>{if(!W.current)return;const Ze=new Set,Ke=[];for(const et of ye.items)for(const Qe of[et.from,et.to]){if(typeof Qe!="string"||Qe.length===0||!Mf.test(Qe))continue;const kt=Qe.toLowerCase();Ze.has(kt)||(Ze.add(kt),Ke.push(Qe))}y(Ke)}).catch(ye=>{Jr({component:Kr,operation:"loadAliases.mail",message:Wr(ye)})}).finally(Re)},[Q,G,i,r]);b.useEffect(()=>(W.current=!0,()=>{W.current=!1,O.current!==null&&(clearTimeout(O.current),O.current=null)}),[]),b.useEffect(()=>{const pe=p.current;p.current=i,pe!==i&&s===pe&&u(Uf(i))},[i,s]);const ue=b.useMemo(()=>RI({operator:i,sessionAliases:d.includes(s)?d:[...d,s],mailFromOrTo:g}),[d,g,s,i]),de=b.useMemo(()=>({viewingAs:{alias:s,isOperator:s===i},setAlias:H,resetToOperator:oe,aliasBuckets:ue,aliasesLoading:E,sessionsUnavailable:T,loadAliases:ee}),[s,i,H,oe,ue,E,T,ee]);return b.useEffect(()=>{const pe=()=>{document.hidden&&s!==i&&(u(i),Zl(i,i))};return document.addEventListener("visibilitychange",pe),()=>document.removeEventListener("visibilitychange",pe)},[s,i]),$.jsx(Tv.Provider,{value:de,children:t})}function LI(){const t=b.useContext(Tv);if(t===null)throw new Error("useViewingAs must be inside ");return t}const DI={id:"activity",kind:"core",path:"/activity",nav:{label:"Activity",order:55},element:b.lazy(()=>wn(()=>import("./Activity-DTboxwTI.js"),__vite__mapDeps([0,1,2,3,4])).then(t=>({default:t.ActivityPage})))},MI={id:"health",kind:"core",path:"/health",nav:{label:"Health",order:60},element:b.lazy(()=>wn(()=>import("./Health-C5mLLJQ2.js"),__vite__mapDeps([5,1,2,4,6,3])).then(t=>({default:t.HealthPage})))},Bv=[DI,MI],FI={views:"views"};function UI(t,r){console.warn(`[${t}] ${r}`)}function Rv(t,r){const i=new Set(r??[]);return t.filter(s=>s.kind==="core"||i.has(s.id))}const ZI={};function qI(t,r){const i=[];if(r!==null){const d=ZI[r];if(d!==void 0){if(t.some(g=>g.id===d.target))return{view:null,redirectTo:d.redirectTo,source:"env",warnings:i};i.push(`DEFAULT_VIEW="${r}" alias targets the "${d.target}" view, which is not enabled in this deployment (known enabled ids: ${t.map(g=>g.id).join(", ")||"(none)"}); falling through to descriptor / ambient-home`)}else{const m=t.find(g=>g.id===r);if(m!==void 0)return{view:m,source:"env",warnings:i};i.push(`DEFAULT_VIEW="${r}" does not match any enabled view (known enabled ids: ${t.map(g=>g.id).join(", ")||"(none)"}); falling through to descriptor / ambient-home`)}}const s=t.filter(d=>d.defaultRoute===!0),[u,...p]=s;if(u!==void 0&&p.length===0)return{view:u,source:"descriptor",warnings:i};if(u!==void 0){const m=[...s].sort(WI)[0]??u;return i.push(`multiple views declare defaultRoute: true (${s.map(g=>g.id).join(", ")}); picking "${m.id}" by lowest nav.order`),{view:m,source:"descriptor",warnings:i}}return{view:null,source:"fallback",warnings:i}}function VI(t,r){const i=qI(t,r);for(const s of i.warnings)UI(FI.views,s);return i}function WI(t,r){const i=t.nav?.order??Number.POSITIVE_INFINITY,s=r.nav?.order??Number.POSITIVE_INFINITY;return i!==s?i-s:t.id.localeCompare(r.id)}const HI=[{to:"/",label:"Home",end:!0,order:10},{to:"/agents",label:"Agents",order:20},{to:"/beads",label:"Beads",order:30},{to:"/runs",label:"Runs",order:40},{to:"/mail",label:"Mail",order:50}],GI={"/agents":"agents","/beads":"beads","/runs":"runs","/mail":"mail","/activity":"activity","/health":"health"};function JI(){const{resolved:t,toggle:r}=xI(),{viewingAs:i}=LI(),{operatorAlias:s}=bv(),u=CI(),p=qy(),{data:d}=mn("config",()=>Yr.config()),{data:m}=mn("cities",()=>ot().listCities()),g=Ba(),y=m?.items??[],E=g??d?.cityName??"",S=E===""||y.some(H=>H.name===E),T=y.length>1||!S,A=H=>{H!==g&&window.location.assign(`/city/${encodeURIComponent(H)}/`)},D=b.useMemo(()=>{const oe=Rv(Bv,d?.enabledModules??null).flatMap(Q=>Q.nav===null?[]:[{to:Q.path,label:Q.nav.label,end:Q.path==="/",order:Q.nav.order}]);return[...HI,...oe].sort((Q,G)=>Q.order-G.order)},[d?.enabledModules]),{pathname:W}=_n(),O=!i.isOperator&&W.startsWith("/mail");return $.jsx("header",{className:"border-b border-rule",children:$.jsxs("div",{className:"max-w-dashboard mx-auto px-4 sm:px-6 lg:px-8 py-5 flex items-baseline gap-x-6 lg:gap-x-8 gap-y-2 flex-wrap",children:[$.jsxs("div",{className:"flex items-baseline gap-3 min-w-0",children:[$.jsx("span",{className:"text-title font-semibold tracking-tight text-fg",children:"gas city"}),$.jsx("span",{className:"text-fg-muted","aria-hidden":"true",children:"·"}),T?$.jsx("label",{className:"sr-only",htmlFor:"city-switcher",children:"Switch city"}):null,T?$.jsxs("select",{id:"city-switcher",value:E,onChange:H=>A(H.target.value),className:"text-label uppercase tracking-wider text-fg-muted bg-transparent border-0 focus-mark cursor-pointer hover:text-fg transition-colors duration-150 ease-out-quart",children:[!S&&E!==""?$.jsxs("option",{value:E,disabled:!0,children:[E," (unknown)"]}):null,y.map(H=>$.jsxs("option",{value:H.name,children:[H.name,H.running?"":" (stopped)"]},H.name))]}):$.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:E||"city"}),O&&$.jsxs("span",{className:"text-label uppercase tracking-wider text-accent ml-3",children:["· reading as ",PI(i.alias,s)]}),u&&$.jsx("span",{title:Cv,className:"text-label uppercase tracking-wider text-warn ml-3",children:"· read-only"})]}),$.jsx("nav",{className:"flex-1",children:$.jsx("ul",{className:"flex items-baseline gap-x-5 lg:gap-x-7 gap-y-1 flex-wrap",children:D.map(H=>{const oe=GI[H.to];return $.jsx("li",{children:$.jsxs(W0,{to:H.to,end:H.end??!1,className:({isActive:Q})=>["text-title transition-colors duration-150 ease-out-quart focus-mark",Q?"text-fg font-semibold":"text-fg-muted font-medium hover:text-fg"].join(" "),children:[H.label,oe!==void 0&&$.jsx(vI,{label:H.label,summary:p.byDomain[oe]})]})},H.to)})})}),$.jsx("button",{type:"button",onClick:r,"aria-label":`Switch to ${t==="dark"?"light":"dark"} theme`,className:"text-label uppercase tracking-wider text-fg-muted hover:text-fg transition-colors duration-150 ease-out-quart focus-mark",children:t==="dark"?"Light":"Dark"})]})})}function KI({children:t}){return $.jsxs("div",{className:"min-h-screen bg-surface text-fg antialiased",children:[$.jsx(JI,{}),$.jsx("main",{className:"max-w-dashboard mx-auto px-4 sm:px-6 lg:px-8 py-12",children:t})]})}const Pv=b.createContext(null);function QI({children:t,intervalMs:r=1e3}){const[i,s]=b.useState(()=>Date.now());return b.useEffect(()=>{const u=window.setInterval(()=>{s(Date.now())},r);return()=>{window.clearInterval(u)}},[r]),$.jsx(Pv.Provider,{value:i,children:t})}function Q6(){const t=b.useContext(Pv);if(t===null)throw new Error("useNow must be called inside a NowProvider.");return t}const YI=2e3,XI=2500;function e6(t,r,i={}){const[s,u]=b.useState("connecting"),p=b.useRef(r);p.current=r;const d=b.useRef(i.matches);d.current=i.matches;const m=b.useRef(i.coalesceMs);m.current=i.coalesceMs;const g=t.join(","),y=b.useRef(0),E=b.useRef(null);return b.useEffect(()=>{if(t.length===0){u("closed");return}let S=null,T=!1,A=null,D=null,W=1e3,O=!1;const H=()=>{D!==null&&(clearTimeout(D),D=null)},oe=ue=>{O||(O=!0,t6(ue))},Q=()=>{y.current=Date.now(),p.current()},G=()=>{const ue=m.current??XI,de=Date.now()-y.current;de>=ue?(E.current&&(clearTimeout(E.current),E.current=null),Q()):E.current===null&&(E.current=setTimeout(()=>{E.current=null,T||Q()},ue-de))},ee=()=>{const ue=globalThis.EventSource;if(typeof ue!="function"){u("closed");return}const de=Ba();if(de===null){u("closed");return}const pe=new ue(ot().cityEventStreamUrl(de));S=pe,u("connecting"),D=setTimeout(()=>{T||S!==pe||pe.readyState===ue.CLOSED||u("open")},YI),S.onopen=()=>{T||(H(),u("open"),W=1e3)};const Re=ye=>{if(T)return;let Ze=null;try{Ze=JSON.parse(ye.data)}catch{u("degraded"),oe("invalid JSON");return}if(!n6(Ze)){u("degraded"),oe("missing string event type");return}const Ke=Ze.type;if(typeof Ke!="string"){u("degraded"),oe("missing string event type");return}u("open");for(const et of t)if(Ke.startsWith(et)){const Qe=Ze;(d.current?.(Qe)??!0)&&G();break}};S.onmessage=Re,S.addEventListener("event",Re),S.onerror=()=>{T||(H(),u("closed"),S?.close(),S=null,A=setTimeout(()=>{W=Math.min(W*2,3e4),ee()},W))}};return ee(),()=>{T=!0,A&&clearTimeout(A),H(),E.current&&(clearTimeout(E.current),E.current=null),S?.close()}},[g]),s}function t6(t){Jr({component:"gc-events",operation:"parse event",message:`Malformed gc event payload: ${t}.`})}function n6(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)}const r6=60*1e3;async function Da(){const t=new Date().toISOString();try{const r=await Yr.runSummary();return{source:"runs",status:"fresh",fetchedAt:t,staleAt:new Date(Date.parse(t)+r6).toISOString(),error:{kind:"none"},data:r}}catch(r){return{source:"runs",status:"error",error:s6(r,"formula runs unavailable")}}}function o6(){return Da()}function Y6(){return Da()}function i6(){return Da()}function a6(){return Da()}function s6(t,r){return t instanceof Error&&t.message.trim().length>0?t.message:r}const Zf=1e4,l6=[2e3,5e3,1e4];function u6(){const t=Ba(),r=b.useRef(null),i=b.useRef(!1),s=b.useCallback(async()=>{const ee=await o6().catch(de=>({source:"runs",status:"error",error:de instanceof Error?de.message:"formula runs unavailable"}));if(ee.status!=="error")return i.current=!1,ee;const ue=r.current;return ue===null?ee:(i.current=!0,{...ue,status:"stale"})},[]),u=b.useCallback(async()=>{const ee=await i6().catch(de=>({source:"runs",status:"error",error:de instanceof Error?de.message:"formula runs unavailable"}));if(ee.status!=="error")return ee;const ue=r.current;return ue===null?ee:(i.current=!0,{...ue,status:"stale"})},[]),{data:p,loading:d,error:m,refresh:g,cheapRefresh:y}=mn(`runs:summary:${t??"no-city"}`,a6,{refreshFetcher:s,sseRefreshFetcher:u});p!==void 0&&p.status!=="error"&&(r.current=p);const E=p??null,S=b.useRef(null);S.current=E?.status??null;const T=b.useRef(d);T.current=d;const A=b.useRef(0),D=b.useRef(null);b.useEffect(()=>{if(E===null||E.status==="error")return;const ee=t??"no-city";D.current!==ee&&(D.current=ee,g().catch(()=>{D.current=null}))},[t,g,E]);const W=b.useRef(0);b.useEffect(()=>{if(E===null)return;if(!(E.status==="error"?!0:i.current||E.data.lanesPartial===!0&&E.data.lanes.length===0&&E.data.blockedLanes.length===0)){W.current=0;return}const ue=l6[W.current];if(ue===void 0)return;W.current+=1;const de=setTimeout(()=>{g()},ue);return()=>clearTimeout(de)},[E,g]);const O=b.useRef(!1),H=b.useRef(null),oe=b.useCallback(()=>{H.current!==null&&(clearTimeout(H.current),H.current=null),A.current=Date.now(),y().catch(()=>{A.current=0})},[y]),Q=b.useCallback(()=>{if(S.current===null||S.current==="fixture")return;if(T.current){O.current=!0;return}Date.now()-A.current{if(d||!O.current)return;O.current=!1;const ee=Math.max(0,Zf-(Date.now()-A.current));return H.current=setTimeout(oe,ee),()=>{H.current!==null&&(clearTimeout(H.current),H.current=null)}},[d,oe]);const G=e6([ly.bead],Q);return{source:p,loading:d,error:m,refresh:g,sseState:G}}const Nv=b.createContext(null);function c6({children:t}){const r=u6();return $.jsx(Nv.Provider,{value:r,children:t})}function d6(){const t=b.useContext(Nv);if(t===null)throw new Error("useRunSummary must be used within a RunSummaryProvider");return t}const p6=b.lazy(()=>wn(()=>import("./Agents-CF9gHKR0.js"),__vite__mapDeps([7,8,1,9,10,2,11,3,12,6,13,14])).then(t=>({default:t.AgentsPage}))),f6=b.lazy(()=>wn(()=>import("./AgentDetail-DVT9Be-a.js"),__vite__mapDeps([15,16,6,17,12,3,10,2,8,14])).then(t=>({default:t.AgentDetailPage}))),m6=b.lazy(()=>wn(()=>import("./AmbientHome-usE4zKNv.js"),__vite__mapDeps([18,2])).then(t=>({default:t.AmbientHomePage}))),v6=b.lazy(()=>wn(()=>import("./Beads-DJjixOgD.js"),__vite__mapDeps([19,1,16,6,17,12,3,10,20,9,2,14])).then(t=>({default:t.BeadsPage}))),h6=b.lazy(()=>wn(()=>import("./Mail-767k9Nkh.js"),__vite__mapDeps([21,9,1,20,13,10,2,17,3])).then(t=>({default:t.MailPage}))),g6=b.lazy(()=>wn(()=>import("./FormulaRunDetail-CFys0Xia.js"),__vite__mapDeps([22,2,16,6,17,12,3,10,23])).then(t=>({default:t.FormulaRunDetailPage}))),y6=b.lazy(()=>wn(()=>import("./Runs-BCTFHOlQ.js"),__vite__mapDeps([24,1,2,11,3,23])).then(t=>({default:t.RunsPage})));function _6(){const{data:t,error:r}=mn("config",()=>Yr.config()),i=t?.enabledModules??null,s=t?.defaultView??null,u=TI(t,r),p=II(t),d=b.useMemo(()=>Rv(Bv,i),[i]),m=b.useMemo(()=>VI(d,s),[d,s]),g=m.view?.element??null,y=m.redirectTo??null;return $.jsx(EI,{operator:p,children:$.jsx($I,{children:$.jsx(QI,{children:$.jsx(zI,{readOnly:u,children:$.jsx(c6,{children:$.jsx(w6,{operator:p,children:$.jsxs(KI,{children:[r!==null&&$.jsx(E6,{message:r}),$.jsx(x6,{defaultRedirectTo:y,DefaultViewElement:g,enabledViews:d})]})})})})})})})}function w6({operator:t,children:r}){const{source:i}=d6(),s=iI(t,i);return $.jsx(Zy,{contributors:s,children:r})}function x6({defaultRedirectTo:t,DefaultViewElement:r,enabledViews:i}){const{pathname:s}=_n();return $.jsx(_v,{children:$.jsx(b.Suspense,{fallback:null,children:$.jsxs(N0,{children:[$.jsx(on,{path:"/",element:t!==null?$.jsx(R0,{to:t,replace:!0}):r!==null?$.jsx(r,{}):$.jsx(m6,{})}),$.jsx(on,{path:"/agents",element:$.jsx(p6,{})}),$.jsx(on,{path:"/agents/:slug",element:$.jsx(f6,{})}),$.jsx(on,{path:"/beads",element:$.jsx(v6,{})}),$.jsx(on,{path:"/runs",element:$.jsx(y6,{})}),$.jsx(on,{path:"/runs/:runId",element:$.jsx(g6,{})}),$.jsx(on,{path:"/mail",element:$.jsx(h6,{})}),i.map(u=>{const p=u.element;return $.jsx(on,{path:u.path,element:$.jsx(p,{})},u.id)}),$.jsx(on,{path:"*",element:$.jsx(I6,{})})]})})},s)}function E6({message:t}){return $.jsxs("section",{role:"alert",className:"mb-8 border border-warn/40 rounded-sm px-4 py-3 text-body text-fg-muted",children:[$.jsx("span",{className:"text-warn",children:"config unavailable:"})," ",t," · some controls may be disabled until it loads."]})}function I6(){return $.jsxs("section",{"aria-labelledby":"not-found-title",className:"space-y-3",children:[$.jsx("h1",{id:"not-found-title",className:"text-5xl font-semibold tracking-tight text-fg",children:"Page not found"}),$.jsx("p",{className:"text-title text-fg-muted",children:"No dashboard route matches this path."})]})}const S6={default:"border border-rule text-fg-muted hover:text-fg hover:bg-surface-tint",accent:"border border-accent text-accent hover:bg-accent hover:text-surface",quiet:"border border-transparent text-fg-muted hover:text-fg"},k6={sm:"px-2.5 py-1 text-label uppercase tracking-wider",md:"px-3.5 py-1.5 text-body"};function b6({tone:t="default",size:r="sm",className:i="",children:s,...u}){return $.jsx("button",{...u,className:`inline-flex items-center gap-1.5 rounded-sm transition-colors duration-150 ease-out-quart focus-mark disabled:opacity-40 disabled:cursor-not-allowed ${S6[t]} ${k6[r]} ${i}`,children:s})}const z6="https://docs.gascity.com/getting-started/quickstart",C6=/^\/city\/([^/]+)(?:\/|$)/;function T6(t){const r=C6.exec(t);if(r===null)return null;const i=r[1];if(i===void 0)return null;let s;try{s=decodeURIComponent(i)}catch{return null}return om.test(s)?{cityName:s,basename:`/city/${i}`}:null}function B6(){const t=b.useMemo(()=>T6(window.location.pathname),[]),[r,i]=b.useState({phase:"loading"}),[s,u]=b.useState(0),p=b.useCallback(()=>{i({phase:"loading"}),u(d=>d+1)},[]);return b.useEffect(()=>{let d=!1;return i({phase:"loading"}),ot().listCities().then(m=>{if(d)return;const g=m.items??[];if(t!==null){const E=g.some(S=>S.name===t.cityName);i(E?{phase:"mount"}:{phase:"unknown-city",cities:g});return}const y=g[0];if(y===void 0){i({phase:"empty"});return}window.location.replace(`/city/${encodeURIComponent(y.name)}/`)}).catch(m=>{if(!d){if(t!==null){i({phase:"mount"});return}i({phase:"error",message:m instanceof Error?m.message:"failed to load cities"})}}),()=>{d=!0}},[t,s]),t!==null&&r.phase==="mount"?(vy(t.cityName),$.jsx(U0,{basename:t.basename,future:{v7_relativeSplatPath:!0,v7_startTransition:!0},children:$.jsx(_6,{})})):r.phase==="unknown-city"&&t!==null?$.jsx(R6,{cityName:t.cityName,cities:r.cities}):r.phase==="empty"?$.jsx(P6,{}):r.phase==="error"?$.jsx(N6,{message:r.message,onRetry:p}):$.jsx(Ma,{children:$.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Resolving city…"})})}function Ma({children:t}){return $.jsx("div",{className:"min-h-screen bg-surface text-fg antialiased flex items-center justify-center px-6",children:$.jsx("div",{className:"max-w-prose w-full space-y-4",children:t})})}function R6({cityName:t,cities:r}){return $.jsx(Ma,{children:$.jsxs("section",{role:"alert",className:"space-y-4",children:[$.jsxs("h1",{className:"text-display font-semibold text-fg",children:["City “",t,"” is not registered on this supervisor."]}),r.length>0?$.jsxs("div",{className:"space-y-2",children:[$.jsx("p",{className:"text-body text-fg-muted",children:"Available cities:"}),$.jsx("ul",{className:"space-y-1",children:r.map(i=>$.jsxs("li",{children:[$.jsx("a",{href:`/city/${encodeURIComponent(i.name)}/`,className:"text-body text-accent hover:underline focus-mark",children:i.name}),i.running?null:$.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted ml-2",children:"· stopped"})]},i.name))})]}):$.jsx(Av,{})]})})}function P6(){return $.jsx(Ma,{children:$.jsxs("section",{className:"space-y-4",children:[$.jsx("h1",{className:"text-display font-semibold text-fg",children:"No cities are registered on this supervisor."}),$.jsx(Av,{})]})})}function Av(){return $.jsxs("div",{className:"space-y-3",children:[$.jsx("p",{className:"text-body text-fg-muted",children:"Create one from a terminal:"}),$.jsx("pre",{className:"text-body bg-surface-tint rounded-sm px-3 py-2 overflow-x-auto",children:$.jsx("code",{children:"gc init ~/my-city"})}),$.jsxs("p",{className:"text-body text-fg-muted",children:[$.jsx("code",{children:"gc init"})," bootstraps the city directory, registers it with the supervisor, and starts the orchestrator. Then refresh this page. See the"," ",$.jsx("a",{href:z6,target:"_blank",rel:"noreferrer",className:"text-accent hover:underline focus-mark",children:"getting-started guide"})," ","for the full walkthrough."]})]})}function N6({message:t,onRetry:r}){return $.jsx(Ma,{children:$.jsxs("section",{role:"alert",className:"space-y-4",children:[$.jsx("h1",{className:"text-display font-semibold text-fg",children:"Could not load cities."}),$.jsx("p",{className:"text-body text-fg-muted",children:t}),$.jsx(b6,{onClick:r,children:"Retry"})]})})}const Ov=document.getElementById("root");if(!Ov)throw new Error("missing #root");Ug.createRoot(Ov).render($.jsx(Vf.StrictMode,{children:$.jsx(wI,{children:$.jsx(_v,{children:$.jsx(B6,{})})})}));export{Fl as $,Zu as A,b6 as B,Ay as C,wv as D,xv as E,Y6 as F,ly as G,Ba as H,ot as I,xn as J,O6 as K,V0 as L,PI as M,G6 as N,Uu as O,n4 as P,U6 as Q,H6 as R,bI as S,py as T,dy as U,F6 as V,M6 as W,Yr as X,im as Y,Vy as Z,Ny as _,qy as a,D6 as a0,Wn as a1,K6 as a2,V6 as a3,J6 as a4,OI as a5,Lf as a6,oy as a7,d6 as a8,E4 as a9,I4 as aa,UE as ab,mn as b,YE as c,VE as d,X0 as e,e6 as f,CI as g,j6 as h,Cv as i,$ as j,$6 as k,NI as l,_4 as m,q6 as n,Z6 as o,Wr as p,A6 as q,b as r,W6 as s,uu as t,Q6 as u,LI as v,bv as w,Jr as x,L6 as y,Jt as z}; diff --git a/internal/api/dashboardspa/dist/assets/projectOf-CJPpTC86.js b/internal/api/dashboardspa/dist/assets/projectOf-CwPPScnJ.js similarity index 97% rename from internal/api/dashboardspa/dist/assets/projectOf-CJPpTC86.js rename to internal/api/dashboardspa/dist/assets/projectOf-CwPPScnJ.js index 06a829c560..ecb65262cd 100644 --- a/internal/api/dashboardspa/dist/assets/projectOf-CJPpTC86.js +++ b/internal/api/dashboardspa/dist/assets/projectOf-CwPPScnJ.js @@ -1 +1 @@ -import{j as c,H as R}from"./index-BFDP6Xwd.js";function k({value:e,onChange:t,placeholder:n="Search",matchCount:r,totalCount:i,ariaLabel:a="Search list"}){const d=e.length>0&&typeof r=="number"&&typeof i=="number";return c.jsxs("div",{className:"flex items-baseline gap-3 border-b border-rule pb-1",children:[c.jsx("input",{type:"search",value:e,onChange:m=>t(m.target.value),placeholder:n,"aria-label":a,className:"flex-1 bg-transparent border-0 text-body text-fg placeholder:text-fg-faint focus:outline-none focus:ring-0 px-0 py-0.5"}),d&&c.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:[r," / ",i]})]})}const b=/^(.+?)-[a-z0-9]+(?:\.\d+)?$/i;function C(e){return b.exec(e.id)?.[1]??e.id}const o="Orchestration";function u(){return R()??o}const s="(no rig)",l="Maintenance",E=new Set(["mayor","control-dispatcher","oversight-rig.chief-of-staff"]);function g(e){return e.rig&&e.rig.length>0?!1:!!e.template&&E.has(e.template)}const p=/\/control-dispatcher$/;function O(e){return!e.rig||e.rig.length===0?!1:p.test(e.alias??"")}const _=/(?:worker|polecat)(?:-\d+)?$/,f=/(?:\.project-lead|chief-of-staff)$/;function j(e){if(e.state!=="active"&&e.state!=="running"||g(e)||O(e))return!1;const t=e.template??"",n=e.alias??"";if(f.test(t)||f.test(n))return!1;const r=e.session_name;return[t,n,r].filter(a=>a.length>0).map(a=>I(a)).some(a=>_.test(a))}function h(e){return e.toLowerCase().replace(/_/g,"-")}function L(e){if(g(e))return{key:o,label:u()};const t=e.rig??e.pool??e.template;if(!t)return{key:s,label:s};const n=t.split(/[\\/]/).filter(Boolean),r=n[n.length-1]??t;return{key:h(r),label:r}}function w(e){return e.rig&&e.rig.length>0?e.rig:s}const S=new Set(["mayor","control-dispatcher","oversight-rig.chief-of-staff"]),N=new Set(["dog"]);function y(e){return e.rig&&e.rig.length>0?!1:S.has(e.name)}function v(e){return!e.rig||e.rig.length===0?!1:p.test(e.name)}function A(e){if(y(e))return{key:o,label:u()};const t=e.rig&&e.rig.length>0?e.rig:void 0;if(!t&&e.pool&&N.has(e.pool))return{key:l,label:l};const n=t??e.pool;if(!n)return{key:s,label:s};const r=n.split(/[\\/]/).filter(Boolean),i=T(r[r.length-1]??n);return{key:h(i),label:i}}function T(e){return e.endsWith("-main")?e.slice(0,-5):e}const x=/-(?:gc|td|th|[a-z]{4})-[a-z0-9]*[0-9][a-z0-9]*$/;function I(e){const t=e.trim(),n=t.split(/[\\/]/).filter(Boolean),r=n[n.length-1]??t,i=r.replace(x,"");return i.length>0?i:r}function H(e){const{key:t}=A(e);return t===o||t===l||t===s}export{k as L,T as a,H as b,I as c,A as d,v as e,C as f,j as i,w as m,L as s}; +import{j as c,H as R}from"./index-C20tCZFz.js";function k({value:e,onChange:t,placeholder:n="Search",matchCount:r,totalCount:i,ariaLabel:a="Search list"}){const d=e.length>0&&typeof r=="number"&&typeof i=="number";return c.jsxs("div",{className:"flex items-baseline gap-3 border-b border-rule pb-1",children:[c.jsx("input",{type:"search",value:e,onChange:m=>t(m.target.value),placeholder:n,"aria-label":a,className:"flex-1 bg-transparent border-0 text-body text-fg placeholder:text-fg-faint focus:outline-none focus:ring-0 px-0 py-0.5"}),d&&c.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:[r," / ",i]})]})}const b=/^(.+?)-[a-z0-9]+(?:\.\d+)?$/i;function C(e){return b.exec(e.id)?.[1]??e.id}const o="Orchestration";function u(){return R()??o}const s="(no rig)",l="Maintenance",E=new Set(["mayor","control-dispatcher","oversight-rig.chief-of-staff"]);function g(e){return e.rig&&e.rig.length>0?!1:!!e.template&&E.has(e.template)}const p=/\/control-dispatcher$/;function O(e){return!e.rig||e.rig.length===0?!1:p.test(e.alias??"")}const _=/(?:worker|polecat)(?:-\d+)?$/,f=/(?:\.project-lead|chief-of-staff)$/;function j(e){if(e.state!=="active"&&e.state!=="running"||g(e)||O(e))return!1;const t=e.template??"",n=e.alias??"";if(f.test(t)||f.test(n))return!1;const r=e.session_name;return[t,n,r].filter(a=>a.length>0).map(a=>I(a)).some(a=>_.test(a))}function h(e){return e.toLowerCase().replace(/_/g,"-")}function L(e){if(g(e))return{key:o,label:u()};const t=e.rig??e.pool??e.template;if(!t)return{key:s,label:s};const n=t.split(/[\\/]/).filter(Boolean),r=n[n.length-1]??t;return{key:h(r),label:r}}function w(e){return e.rig&&e.rig.length>0?e.rig:s}const S=new Set(["mayor","control-dispatcher","oversight-rig.chief-of-staff"]),N=new Set(["dog"]);function y(e){return e.rig&&e.rig.length>0?!1:S.has(e.name)}function v(e){return!e.rig||e.rig.length===0?!1:p.test(e.name)}function A(e){if(y(e))return{key:o,label:u()};const t=e.rig&&e.rig.length>0?e.rig:void 0;if(!t&&e.pool&&N.has(e.pool))return{key:l,label:l};const n=t??e.pool;if(!n)return{key:s,label:s};const r=n.split(/[\\/]/).filter(Boolean),i=T(r[r.length-1]??n);return{key:h(i),label:i}}function T(e){return e.endsWith("-main")?e.slice(0,-5):e}const x=/-(?:gc|td|th|[a-z]{4})-[a-z0-9]*[0-9][a-z0-9]*$/;function I(e){const t=e.trim(),n=t.split(/[\\/]/).filter(Boolean),r=n[n.length-1]??t,i=r.replace(x,"");return i.length>0?i:r}function H(e){const{key:t}=A(e);return t===o||t===l||t===s}export{k as L,T as a,H as b,I as c,A as d,v as e,C as f,j as i,w as m,L as s}; diff --git a/internal/api/dashboardspa/dist/assets/useListFilters-CE9qAvrH.js b/internal/api/dashboardspa/dist/assets/useListFilters-C0Eq1DLc.js similarity index 98% rename from internal/api/dashboardspa/dist/assets/useListFilters-CE9qAvrH.js rename to internal/api/dashboardspa/dist/assets/useListFilters-C0Eq1DLc.js index 2c116e5876..6ec63dccad 100644 --- a/internal/api/dashboardspa/dist/assets/useListFilters-CE9qAvrH.js +++ b/internal/api/dashboardspa/dist/assets/useListFilters-C0Eq1DLc.js @@ -1 +1 @@ -import{j as C,r as g,D as Y,E as D,x as tt,p as et}from"./index-BFDP6Xwd.js";function gt({chips:e,activeIds:n,onToggle:a,legend:p}){return e.length===0?null:C.jsxs("div",{className:"flex items-baseline gap-4 flex-wrap",children:[p&&C.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:p}),e.map(i=>{const d=n.has(i.id);return C.jsx("button",{type:"button",onClick:()=>a(i.id),"aria-pressed":d,className:`text-label uppercase tracking-wider transition-colors duration-150 ease-out-quart focus-mark rounded-sm ${d?"text-fg font-semibold underline decoration-fg underline-offset-4":"text-fg-muted hover:text-fg"}`,children:i.label},i.id)})]})}const st="gcd:listFilters:collapsed:",rt="gcd:listFilters:expanded:",X="gcd:listFilters:sortMode:",m="useListFilters";function B(e,n){return(n?rt:st)+e}function R(e,n){const a=B(e,n),p=Y("localStorage",a,m);if(p.status!=="found")return new Set;try{const i=JSON.parse(p.value);if(Array.isArray(i))return new Set(i.filter(d=>typeof d=="string"))}catch(i){at(a,i)}return new Set}function nt(e,n,a){D("localStorage",B(e,n),JSON.stringify(Array.from(a)),m)}function T(e,n){const a=Y("localStorage",X+e,m);return a.status==="found"&&(a.value==="alpha"||a.value==="activity")?a.value:n}function ot(e,n){D("localStorage",X+e,n,m)}function at(e,n){tt({component:m,operation:"localStorage.parse",message:`${e}: ${et(n)}`})}const ct=[],it=new Set,lt=[];function pt({viewKey:e,rows:n,projectOf:a,searchOf:p,chips:i,initialActiveChipIds:d=lt,defaultCollapsed:f=!1,activityOf:x,defaultSortMode:M="alpha",pinnedProjects:k=ct,nonCollapsibleProjects:I=it}){const $=d.join(","),[N,L]=g.useState(""),[P,_]=g.useState(()=>new Set(d)),[w,A]=g.useState(()=>R(e,f)),[F,v]=g.useState(()=>T(e,M));g.useEffect(()=>{A(R(e,f)),v(T(e,M)),L(""),_(new Set(d))},[e,f,M,$]);const H=g.useCallback(r=>{_(S=>{const l=new Set(S);return l.has(r)?l.delete(r):l.add(r),l})},[]),J=g.useCallback(r=>{A(S=>{const l=new Set(S);return l.has(r)?l.delete(r):l.add(r),nt(e,f,l),l})},[e,f]),U=g.useCallback(r=>w.has(r)?!f:f,[w,f]),q=g.useCallback(r=>{v(r),ot(e,r)},[e]),y=g.useMemo(()=>{const r=N.trim().toLowerCase(),S=i.filter(t=>P.has(t.id)),l=t=>{if(r.length===0)return!0;for(const s of p(t))if(s&&s.toLowerCase().includes(r))return!0;return!1},Z=t=>{if(S.length===0)return!0;for(const s of S)if(s.match(t))return!0;return!1},b=new Map;for(const t of n){if(!l(t)||!Z(t))continue;const s=a(t),o=typeof s=="string"?s:s.key,c=typeof s=="string"?s:s.label,u=b.get(o);u?(u.rows.push(t),u.labelCounts.set(c,(u.labelCounts.get(c)??0)+1)):b.set(o,{rows:[t],labelCounts:new Map([[c,1]])})}const z=t=>{let s="",o=-1,c=!1;for(const[u,h]of t){const j=/[A-Z]/.test(u);(h>o||h===o&&j&&!c)&&(s=u,o=h,c=j)}return s},G=Array.from(b.keys()),O=k.filter(t=>b.has(t)),Q=new Set(O),E=G.filter(t=>!Q.has(t));if(F==="activity"&&x){const t=new Map;for(const s of E){const o=b.get(s);let c=-1/0;if(o)for(const u of o.rows){const h=x(u);typeof h=="number"&&Number.isFinite(h)&&h>c&&(c=h)}t.set(s,c)}E.sort((s,o)=>{const c=t.get(s)??-1/0,u=t.get(o)??-1/0;return c!==u?u-c:s.localeCompare(o)})}else E.sort();const V=[...O,...E],W=t=>I.has(t)?!1:w.has(t)?!f:f;return V.map(t=>{const s=b.get(t),o=s?.rows??[];return{project:s?z(s.labelCounts):t,projectKey:t,rows:o,totalInProject:o.length,collapsed:W(t),collapsible:!I.has(t)}})},[n,N,P,i,a,p,w,f,F,x,k,I]),K=g.useMemo(()=>y.reduce((r,S)=>r+S.totalInProject,0),[y]);return{search:N,setSearch:L,activeChipIds:P,toggleChip:H,isCollapsed:U,toggleProject:J,sortMode:F,setSortMode:q,groups:y,totalMatches:K}}export{gt as F,pt as u}; +import{j as C,r as g,D as Y,E as D,x as tt,p as et}from"./index-C20tCZFz.js";function gt({chips:e,activeIds:n,onToggle:a,legend:p}){return e.length===0?null:C.jsxs("div",{className:"flex items-baseline gap-4 flex-wrap",children:[p&&C.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:p}),e.map(i=>{const d=n.has(i.id);return C.jsx("button",{type:"button",onClick:()=>a(i.id),"aria-pressed":d,className:`text-label uppercase tracking-wider transition-colors duration-150 ease-out-quart focus-mark rounded-sm ${d?"text-fg font-semibold underline decoration-fg underline-offset-4":"text-fg-muted hover:text-fg"}`,children:i.label},i.id)})]})}const st="gcd:listFilters:collapsed:",rt="gcd:listFilters:expanded:",X="gcd:listFilters:sortMode:",m="useListFilters";function B(e,n){return(n?rt:st)+e}function R(e,n){const a=B(e,n),p=Y("localStorage",a,m);if(p.status!=="found")return new Set;try{const i=JSON.parse(p.value);if(Array.isArray(i))return new Set(i.filter(d=>typeof d=="string"))}catch(i){at(a,i)}return new Set}function nt(e,n,a){D("localStorage",B(e,n),JSON.stringify(Array.from(a)),m)}function T(e,n){const a=Y("localStorage",X+e,m);return a.status==="found"&&(a.value==="alpha"||a.value==="activity")?a.value:n}function ot(e,n){D("localStorage",X+e,n,m)}function at(e,n){tt({component:m,operation:"localStorage.parse",message:`${e}: ${et(n)}`})}const ct=[],it=new Set,lt=[];function pt({viewKey:e,rows:n,projectOf:a,searchOf:p,chips:i,initialActiveChipIds:d=lt,defaultCollapsed:f=!1,activityOf:x,defaultSortMode:M="alpha",pinnedProjects:k=ct,nonCollapsibleProjects:I=it}){const $=d.join(","),[N,L]=g.useState(""),[P,_]=g.useState(()=>new Set(d)),[w,A]=g.useState(()=>R(e,f)),[F,v]=g.useState(()=>T(e,M));g.useEffect(()=>{A(R(e,f)),v(T(e,M)),L(""),_(new Set(d))},[e,f,M,$]);const H=g.useCallback(r=>{_(S=>{const l=new Set(S);return l.has(r)?l.delete(r):l.add(r),l})},[]),J=g.useCallback(r=>{A(S=>{const l=new Set(S);return l.has(r)?l.delete(r):l.add(r),nt(e,f,l),l})},[e,f]),U=g.useCallback(r=>w.has(r)?!f:f,[w,f]),q=g.useCallback(r=>{v(r),ot(e,r)},[e]),y=g.useMemo(()=>{const r=N.trim().toLowerCase(),S=i.filter(t=>P.has(t.id)),l=t=>{if(r.length===0)return!0;for(const s of p(t))if(s&&s.toLowerCase().includes(r))return!0;return!1},Z=t=>{if(S.length===0)return!0;for(const s of S)if(s.match(t))return!0;return!1},b=new Map;for(const t of n){if(!l(t)||!Z(t))continue;const s=a(t),o=typeof s=="string"?s:s.key,c=typeof s=="string"?s:s.label,u=b.get(o);u?(u.rows.push(t),u.labelCounts.set(c,(u.labelCounts.get(c)??0)+1)):b.set(o,{rows:[t],labelCounts:new Map([[c,1]])})}const z=t=>{let s="",o=-1,c=!1;for(const[u,h]of t){const j=/[A-Z]/.test(u);(h>o||h===o&&j&&!c)&&(s=u,o=h,c=j)}return s},G=Array.from(b.keys()),O=k.filter(t=>b.has(t)),Q=new Set(O),E=G.filter(t=>!Q.has(t));if(F==="activity"&&x){const t=new Map;for(const s of E){const o=b.get(s);let c=-1/0;if(o)for(const u of o.rows){const h=x(u);typeof h=="number"&&Number.isFinite(h)&&h>c&&(c=h)}t.set(s,c)}E.sort((s,o)=>{const c=t.get(s)??-1/0,u=t.get(o)??-1/0;return c!==u?u-c:s.localeCompare(o)})}else E.sort();const V=[...O,...E],W=t=>I.has(t)?!1:w.has(t)?!f:f;return V.map(t=>{const s=b.get(t),o=s?.rows??[];return{project:s?z(s.labelCounts):t,projectKey:t,rows:o,totalInProject:o.length,collapsed:W(t),collapsible:!I.has(t)}})},[n,N,P,i,a,p,w,f,F,x,k,I]),K=g.useMemo(()=>y.reduce((r,S)=>r+S.totalInProject,0),[y]);return{search:N,setSearch:L,activeChipIds:P,toggleChip:H,isCollapsed:U,toggleProject:J,sortMode:F,setSortMode:q,groups:y,totalMatches:K}}export{gt as F,pt as u}; diff --git a/internal/api/dashboardspa/dist/assets/useVisibleRefresh-Bxd6CPUo.js b/internal/api/dashboardspa/dist/assets/useVisibleRefresh-D_HCcAAw.js similarity index 92% rename from internal/api/dashboardspa/dist/assets/useVisibleRefresh-Bxd6CPUo.js rename to internal/api/dashboardspa/dist/assets/useVisibleRefresh-D_HCcAAw.js index be003a7229..1188e560fa 100644 --- a/internal/api/dashboardspa/dist/assets/useVisibleRefresh-Bxd6CPUo.js +++ b/internal/api/dashboardspa/dist/assets/useVisibleRefresh-D_HCcAAw.js @@ -1 +1 @@ -import{r}from"./index-BFDP6Xwd.js";const F=2e3,w=6e4;function h(e,u,s={}){const a=r.useRef(e);a.current=e;const n=r.useRef(l(s));n.current=l(s);const t=r.useRef(0),c=r.useRef(0),o=r.useRef(!1),{enabled:i,initialBackoffMs:d,maxBackoffMs:B}=n.current;r.useEffect(()=>{if(!i)return;const M=()=>{t.current=0,c.current=0},R=A=>{const f=n.current;f.onError?.(A);const E=Math.min(f.initialBackoffMs*2**t.current,f.maxBackoffMs);t.current+=1,c.current=Date.now()+E},k=()=>{document.hidden||o.current||Date.now(){o.current=!1}))},m=window.setInterval(k,u);return()=>window.clearInterval(m)},[i,u,d,B])}function l(e){return{enabled:e.enabled??!0,initialBackoffMs:e.initialBackoffMs??F,maxBackoffMs:e.maxBackoffMs??w,onError:e.onError??x}}function x(){}export{h as u}; +import{r}from"./index-C20tCZFz.js";const F=2e3,w=6e4;function h(e,u,s={}){const a=r.useRef(e);a.current=e;const n=r.useRef(l(s));n.current=l(s);const t=r.useRef(0),c=r.useRef(0),o=r.useRef(!1),{enabled:i,initialBackoffMs:d,maxBackoffMs:B}=n.current;r.useEffect(()=>{if(!i)return;const M=()=>{t.current=0,c.current=0},R=A=>{const f=n.current;f.onError?.(A);const E=Math.min(f.initialBackoffMs*2**t.current,f.maxBackoffMs);t.current+=1,c.current=Date.now()+E},k=()=>{document.hidden||o.current||Date.now(){o.current=!1}))},m=window.setInterval(k,u);return()=>window.clearInterval(m)},[i,u,d,B])}function l(e){return{enabled:e.enabled??!0,initialBackoffMs:e.initialBackoffMs??F,maxBackoffMs:e.maxBackoffMs??w,onError:e.onError??x}}function x(){}export{h as u}; diff --git a/internal/api/dashboardspa/dist/index.html b/internal/api/dashboardspa/dist/index.html index 9afd2804f2..9a3c8117ba 100644 --- a/internal/api/dashboardspa/dist/index.html +++ b/internal/api/dashboardspa/dist/index.html @@ -20,7 +20,7 @@ } catch (_) {} })(); - + diff --git a/internal/api/dashboardspa/web/frontend/src/hooks/useFormulaRunDetail.test.tsx b/internal/api/dashboardspa/web/frontend/src/hooks/useFormulaRunDetail.test.tsx index edb7d40d82..8d57dc956a 100644 --- a/internal/api/dashboardspa/web/frontend/src/hooks/useFormulaRunDetail.test.tsx +++ b/internal/api/dashboardspa/web/frontend/src/hooks/useFormulaRunDetail.test.tsx @@ -4,7 +4,7 @@ import type { FormulaRunDetail } from 'gas-city-dashboard-shared'; import { invalidate } from '../api/cache'; import { ApiClientError } from '../api/client'; import { reportClientError } from '../lib/clientErrorReporting'; -import { loadSupervisorFormulaRunDetail } from '../supervisor/runDetail'; +import { loadSupervisorFormulaRunDetail, type LoadRunDetailOptions } from '../supervisor/runDetail'; import { formulaRunDetailCacheKey, useFormulaRunDetail } from './useFormulaRunDetail'; vi.mock('../api/cityBase', () => ({ @@ -114,7 +114,8 @@ describe('useFormulaRunDetail', () => { expect('diff' in result.current).toBe(false); // The loader is scope-independent now (the projection derives scope from the // run's own root bead); the route's scope still drives only the cache key. - expect(mockLoadDetail).toHaveBeenCalledWith('wf-1'); + // The second argument is the warming-poll wiring (onWarming/keepPolling). + expect(mockLoadDetail).toHaveBeenCalledWith('wf-1', expect.anything()); expect(mockReportClientError).not.toHaveBeenCalled(); }); @@ -193,6 +194,94 @@ describe('useFormulaRunDetail', () => { }); }); +describe('useFormulaRunDetail warming poll (F4)', () => { + // The loader polls warming 503s for up to ~180s (covered in runDetail.test.ts) + // and signals each one via onWarming. The hook's job: surface that signal on + // the loading state (so the route can render honest "may still be being + // recorded" copy), clear it when the poll settles, and supersede a stale poll + // (unmount/refresh) so it stops issuing GETs and cannot write stale state. + + it('surfaces the loader warming signal (unknown_run) on the loading state', async () => { + mockLoadDetail.mockImplementation((_runId: string, options?: LoadRunDetailOptions) => { + options?.onWarming?.({ reason: 'unknown_run' }); + return new Promise(() => {}); + }); + + const { result } = renderHook(() => useFormulaRunDetail('wf-1', 'city', 'test-city')); + + await waitFor(() => + expect(result.current).toMatchObject({ + kind: 'loading', + warming: { reason: 'unknown_run' }, + }), + ); + }); + + it('carries no warming signal while a plain first GET is pending', async () => { + mockLoadDetail.mockImplementation(() => new Promise(() => {})); + + const { result } = renderHook(() => useFormulaRunDetail('wf-1', 'city', 'test-city')); + + await waitFor(() => expect(result.current).toMatchObject({ kind: 'loading', warming: null })); + }); + + it('does not leak a stale warming signal into a later load', async () => { + // First load: warming unknown_run, then the budget-exhausted 503 → failed. + mockLoadDetail.mockImplementationOnce((_runId: string, options?: LoadRunDetailOptions) => { + options?.onWarming?.({ reason: 'unknown_run' }); + return Promise.reject(new ApiClientError(503, 'run view is warming')); + }); + const { result } = renderHook(() => useFormulaRunDetail('wf-1', 'city', 'test-city')); + await waitFor(() => expect(result.current.kind).toBe('failed')); + + // A later refresh starts a new load that hangs on its FIRST GET (no 503 + // seen yet): its loading state must carry NO warming left over from the + // dead poll. + mockLoadDetail.mockImplementation(() => new Promise(() => {})); + act(() => { + void result.current.refresh(); + }); + await waitFor(() => expect(result.current).toMatchObject({ kind: 'loading', warming: null })); + }); + + it('supersedes the warming poll on unmount so it stops issuing GETs', async () => { + let captured: LoadRunDetailOptions | undefined; + mockLoadDetail.mockImplementation((_runId: string, options?: LoadRunDetailOptions) => { + captured = options; + return new Promise(() => {}); + }); + + const { unmount } = renderHook(() => useFormulaRunDetail('wf-1', 'city', 'test-city')); + await waitFor(() => expect(captured).toBeDefined()); + expect(captured?.keepPolling?.()).toBe(true); + + unmount(); + + expect(captured?.keepPolling?.()).toBe(false); + }); + + it('supersedes an in-flight warming poll when a refresh starts a newer load', async () => { + const options: LoadRunDetailOptions[] = []; + mockLoadDetail.mockImplementation((_runId: string, opts?: LoadRunDetailOptions) => { + if (opts) options.push(opts); + return new Promise(() => {}); + }); + + const { result } = renderHook(() => useFormulaRunDetail('wf-1', 'city', 'test-city')); + await waitFor(() => expect(options).toHaveLength(1)); + expect(options[0]?.keepPolling?.()).toBe(true); + + act(() => { + void result.current.refresh(); + }); + + await waitFor(() => expect(options).toHaveLength(2)); + // The older poll is dead; the newest owns the warming state. + expect(options[0]?.keepPolling?.()).toBe(false); + expect(options[1]?.keepPolling?.()).toBe(true); + }); +}); + describe('useFormulaRunDetail SSE stream integration (P4)', () => { const eventSources = streamEventSources; diff --git a/internal/api/dashboardspa/web/frontend/src/hooks/useFormulaRunDetail.ts b/internal/api/dashboardspa/web/frontend/src/hooks/useFormulaRunDetail.ts index f36414093e..3724053ca6 100644 --- a/internal/api/dashboardspa/web/frontend/src/hooks/useFormulaRunDetail.ts +++ b/internal/api/dashboardspa/web/frontend/src/hooks/useFormulaRunDetail.ts @@ -1,8 +1,12 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { FormulaRunDetail, RunScopeKind } from 'gas-city-dashboard-shared'; import { errorMessage } from 'gas-city-dashboard-shared'; import { reportClientError } from '../lib/clientErrorReporting'; -import { loadSupervisorFormulaRunDetail } from '../supervisor/runDetail'; +import { + loadSupervisorFormulaRunDetail, + type LoadRunDetailOptions, + type RunDetailWarming, +} from '../supervisor/runDetail'; import { ApiClientError } from '../api/client'; import { useCachedData } from './useCachedData'; import { useFormulaRunDetailStream } from './useFormulaRunDetailStream'; @@ -52,7 +56,12 @@ type FormulaRunDetailPayload = export type FormulaRunDetailLoadState = | (FormulaRunDetailState & { kind: 'idle' }) - | (FormulaRunDetailState & { kind: 'loading' }) + // F4: while the initial load polls the BFF's warming 503s (the just-slung + // deep-link case, up to ~180s), `warming` carries the loader's signal — with + // reason 'unknown_run' when the projection is warm but has not seen the run + // yet — so the route can render honest "may still be being recorded" copy + // instead of an anonymous spinner. Null while no warming 503 has been seen. + | (FormulaRunDetailState & { kind: 'loading'; warming: RunDetailWarming | null }) | (FormulaRunDetailState & { kind: 'ready'; detail: FormulaRunDetail; @@ -68,16 +77,47 @@ export function useFormulaRunDetail( scopeRef?: string, ): FormulaRunDetailLoadState { const key = formulaRunDetailCacheKey(runId, scopeKind, scopeRef); + // F4: the loader polls warming 503s for up to ~180s (the just-slung + // deep-link grace window). Each fetcher invocation gets a generation; a + // newer invocation (key change, manual refresh, nudge) or unmount + // supersedes older polls via keepPolling, so a superseded poll stops + // issuing GETs and its warming signal can never overwrite the current + // load's state. The settled poll clears its own warming signal so the + // failed/ready states never carry stale interim copy. + const [warming, setWarming] = useState(null); + const pollGenRef = useRef(0); + useEffect( + () => () => { + pollGenRef.current += 1; + }, + [], + ); const { data, loading, error, refresh: cachedRefresh, - } = useCachedData(key, () => loadFormulaRunDetail(runId), { - onError: (err) => { - if (runId !== undefined) reportRunDetailError('load detail', runId, err); + } = useCachedData( + key, + () => { + const gen = ++pollGenRef.current; + const isCurrent = () => pollGenRef.current === gen; + const load = loadFormulaRunDetail(runId, { + onWarming: (next) => { + if (isCurrent()) setWarming(next); + }, + keepPolling: isCurrent, + }); + return load.finally(() => { + if (isCurrent()) setWarming(null); + }); }, - }); + { + onError: (err) => { + if (runId !== undefined) reportRunDetailError('load detail', runId, err); + }, + }, + ); // P4: the per-run SSE stream pushes the whole DTO, so a pushed frame becomes // the rendered detail with ZERO refetch. The stream hook warms the SWR cache @@ -148,13 +188,16 @@ export function useFormulaRunDetail( if (data?.kind === 'unsupported') return { kind: 'unsupported', refresh, streamActive }; if (data?.kind === 'not_found') return { kind: 'not_found', refresh, streamActive }; if (error !== null) return { kind: 'failed', error, refresh, streamActive }; - return { kind: 'loading', refresh, streamActive }; + return { kind: 'loading', warming, refresh, streamActive }; } -async function loadFormulaRunDetail(runId: string | undefined): Promise { +async function loadFormulaRunDetail( + runId: string | undefined, + options?: LoadRunDetailOptions, +): Promise { if (!runId) return { kind: 'unrequested' }; try { - const detail = await loadSupervisorFormulaRunDetail(runId); + const detail = await loadSupervisorFormulaRunDetail(runId, options); return { kind: 'loaded', detail }; } catch (err) { // gascity-dashboard-9w3k: a v1 / wisp run (not graph.v2) loads but has no diff --git a/internal/api/dashboardspa/web/frontend/src/routes/FormulaRunDetail.test.tsx b/internal/api/dashboardspa/web/frontend/src/routes/FormulaRunDetail.test.tsx index 69abd95275..ca0237ef87 100644 --- a/internal/api/dashboardspa/web/frontend/src/routes/FormulaRunDetail.test.tsx +++ b/internal/api/dashboardspa/web/frontend/src/routes/FormulaRunDetail.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FormulaRunDetailPage, runDetailNudgeRefresh } from './FormulaRunDetail'; @@ -20,6 +20,8 @@ import { import { ApiClientError } from '../api/client'; import rawFormulaRunDetailFixture from '../test/fixtures/formula-run-detail.json'; +import type { LoadRunDetailOptions } from '../supervisor/runDetail'; + const loadSupervisorFormulaRunDetail = vi.hoisted(() => vi.fn()); vi.mock('../supervisor/runDetail', () => ({ @@ -446,22 +448,108 @@ describe('FormulaRunDetailPage', () => { expect(diffUrls()).toHaveLength(2); }); - it('does not refresh from city events before the initial run detail identifies the run', async () => { + it('refreshes a not-yet-loaded run from city events anchored on the ROUTE runId (F4)', async () => { + // The printed deep-link case: before the initial detail load resolves (or + // after it failed), the run's own eventual bead events must nudge a + // refresh — the matcher anchors on the route's runId, never on a loaded + // detail (the old `detail === null → return false` early-return made the + // failed state permanent). Events identifying a DIFFERENT run stay + // ignored; an identity-less (ambient) event matches, mirroring the + // non-terminal ambient behavior after load — a root bead's own events may + // carry no run identity. const initialLoad = deferred(); loadSupervisorFormulaRunDetail.mockReturnValue(initialLoad.promise); renderPage(); const cityStream = requireCityEventSource(); + // The SSE precheck 503 is fatal to EventSource: the detail stream closes + // terminally, releasing the nudge lane back to detail refreshes. + act(() => requireRunDetailStream().fail()); expect(loadSupervisorFormulaRunDetail).toHaveBeenCalledTimes(1); - cityStream.dispatch('event', { type: `${GC_EVENT_PREFIX.bead}updated` }); + // Another run's event: no refresh. + cityStream.dispatch('event', { + type: `${GC_EVENT_PREFIX.bead}updated`, + payload: { bead: { metadata: { 'gc.run_id': 'other-run' } } }, + }); await Promise.resolve(); - expect(loadSupervisorFormulaRunDetail).toHaveBeenCalledTimes(1); + + // This run's event: the detail refresh fires even though no detail ever + // loaded. + cityStream.dispatch('event', { + type: `${GC_EVENT_PREFIX.bead}updated`, + payload: { bead: { metadata: { 'gc.run_id': 'gc-adopt-pr-active' } } }, + }); + await waitFor(() => expect(loadSupervisorFormulaRunDetail).toHaveBeenCalledTimes(2)); + initialLoad.resolve(detail); await screen.findByRole('heading', { name: /adopt pr #42/i }); }); + it('recovers a failed warming load when the run’s bead events later arrive (F4)', async () => { + // A deep link printed right after `gc sling` can exhaust even the long + // warming budget before the controller's cache-reconcile emits the run's + // bead events. Those eventual events must nudge the page out of the + // failed state — the run's detail loads on the retriggered refresh. + loadSupervisorFormulaRunDetail.mockRejectedValueOnce( + new ApiClientError(503, 'run view is warming', undefined, 'unknown_run'), + ); + + renderPage(); + await screen.findByRole('alert'); + const cityStream = requireCityEventSource(); + act(() => requireRunDetailStream().fail()); + expect(loadSupervisorFormulaRunDetail).toHaveBeenCalledTimes(1); + + cityStream.dispatch('event', { + type: `${GC_EVENT_PREFIX.bead}updated`, + payload: { bead: { metadata: { 'gc.run_id': 'gc-adopt-pr-active' } } }, + }); + + await screen.findByRole('heading', { name: /adopt pr #42/i }); + expect(loadSupervisorFormulaRunDetail).toHaveBeenCalledTimes(2); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('renders honest recording copy while an unknown run is inside its warming grace (F4)', async () => { + // While the loader polls the graced 503 (body reason 'unknown_run': the + // projection is warm but has never seen this run), the interim copy must + // say honestly that the run may still be being recorded — or may not + // exist — rather than implying a client-side wait bug or failing outright. + loadSupervisorFormulaRunDetail.mockImplementation( + (_runId: string, options?: LoadRunDetailOptions) => { + options?.onWarming?.({ reason: 'unknown_run' }); + return new Promise(() => {}); + }, + ); + + renderPage(); + + const status = await screen.findByRole('status'); + expect(status.textContent).toMatch(/may still be being recorded/i); + expect(status.textContent).toMatch(/couple of minutes/i); + expect(status.textContent).toMatch(/may no longer exist/i); + // Interim, not terminal: no error alert while the poll is still running. + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('keeps the generic loading copy for a cold-replay warming 503 (no reason)', async () => { + // The projection-still-warming 503 carries no reason: the run is not in + // doubt, the fold just hasn't caught up — so the plain loading copy stays. + loadSupervisorFormulaRunDetail.mockImplementation( + (_runId: string, options?: LoadRunDetailOptions) => { + options?.onWarming?.({ reason: undefined }); + return new Promise(() => {}); + }, + ); + + renderPage(); + + expect(await screen.findByText(/^Loading formula run\.$/i)).toBeTruthy(); + expect(screen.queryByText(/may still be being recorded/i)).toBeNull(); + }); + it('does not load the execution-folder diff before the initial run detail is ready', async () => { const initialLoad = deferred(); loadSupervisorFormulaRunDetail.mockReturnValue(initialLoad.promise); @@ -574,8 +662,12 @@ describe('FormulaRunDetailPage', () => { const runUrls = fetchUrls.filter((url) => url.startsWith('/api/city/test-city/runs/')); // The detail loader is scope-independent now (the BFF projection derives the // run's scope from its own root bead); the route's scope still drives the - // separate run-diff fetch below. - expect(loadSupervisorFormulaRunDetail).toHaveBeenCalledWith('gc-adopt-pr-active'); + // separate run-diff fetch below. The second argument is the warming-poll + // wiring (onWarming/keepPolling). + expect(loadSupervisorFormulaRunDetail).toHaveBeenCalledWith( + 'gc-adopt-pr-active', + expect.anything(), + ); expect(runUrls).toContain( '/api/city/test-city/runs/gc-adopt-pr-active/diff?scope_kind=city&scope_ref=racoon-city', ); diff --git a/internal/api/dashboardspa/web/frontend/src/routes/FormulaRunDetail.tsx b/internal/api/dashboardspa/web/frontend/src/routes/FormulaRunDetail.tsx index 6575f8f325..4966604ccf 100644 --- a/internal/api/dashboardspa/web/frontend/src/routes/FormulaRunDetail.tsx +++ b/internal/api/dashboardspa/web/frontend/src/routes/FormulaRunDetail.tsx @@ -108,8 +108,17 @@ export function FormulaRunDetailPage() { ), { matches: (event) => { - if (detail === null) return false; const identity = runEventIdentity(event); + // F4: before the detail loads (still warming, or the first load + // failed), anchor the match on the ROUTE's runId. A printed deep link + // lands here before the just-slung run's bead events exist, and + // requiring a loaded detail meant those eventual events could never + // nudge the page out of the failed state. An identity-less (ambient) + // event also matches — a root bead's own events may carry no run + // identity — mirroring the non-terminal ambient behavior below. + if (detail === null) { + return runId !== undefined && (identity.runIds.size === 0 || identity.runIds.has(runId)); + } if (detail.progress.terminal && identityIsAmbient(identity)) return false; return formulaRunDetailEventMatches(identity, { runId: detail.runId, @@ -137,6 +146,14 @@ export function FormulaRunDetailPage() { const handleActiveTabChange = useCallback((next: RunEvidenceTab) => setActiveTab(next), []); const pageError = routeError ?? loadError; + // F4: while the initial load polls the graced warming 503 (reason + // 'unknown_run': the projection is warm but has never seen this run — the + // just-slung deep-link window, or a genuinely dead link), the interim copy + // must be honest about BOTH possibilities instead of an anonymous spinner. A + // reason-less warming 503 (the projection itself is cold-replaying) keeps + // the plain loading copy: the run is not in doubt there. + const warmingUnknownRun = + runDetail.kind === 'loading' && runDetail.warming?.reason === 'unknown_run'; const { selectedNodeId, selectedNode, toggleNode } = useRunNodeSelection( detail, initialNodeId, @@ -220,6 +237,11 @@ export function FormulaRunDetailPage() {

Loading run detail.

+ ) : warmingUnknownRun ? ( +

+ This run may still be being recorded — new work can take a couple of minutes to appear — + or it may no longer exist. +

) : (

Loading formula run.

) diff --git a/internal/api/dashboardspa/web/frontend/src/supervisor/runDetail.test.ts b/internal/api/dashboardspa/web/frontend/src/supervisor/runDetail.test.ts index 1ded1a09ee..1d34e151c8 100644 --- a/internal/api/dashboardspa/web/frontend/src/supervisor/runDetail.test.ts +++ b/internal/api/dashboardspa/web/frontend/src/supervisor/runDetail.test.ts @@ -5,9 +5,12 @@ import { loadSupervisorFormulaRunDetail } from './runDetail'; // The detail pipeline (snapshot synthesis, grouping, phase/stage, edges, lanes, // formula identity, completeness) moved to Go (internal/runproj.BuildRunDetail) // and is golden-gated byte-for-byte. The TS loader is now one GET to the BFF -// run-projection endpoint with a bounded retry while the projection is still -// cold-replaying (HTTP 503). This file covers that thin read: the warm path, the -// warming retry, and the error surface the hook maps. +// run-projection endpoint that treats a warming 503 as a poll signal: fast +// initial delays, then a capped cadence, with a total budget sized to the +// server's 180s unknown-run grace window (a just-slung run's bead events land +// 30-120s after sling). This file covers that thin read: the warm path, the +// warming poll (cadence, budget, cancellation, the onWarming signal), and the +// error surface the hook maps. const detailBody = { runId: 'mol-adopt-1', @@ -72,13 +75,102 @@ describe('loadSupervisorFormulaRunDetail', () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); - it('gives up after the warming budget is spent and surfaces the 503', async () => { + it('keeps polling a warming 503 at the capped cadence until the run appears (deep-link grace)', async () => { + // A dashboard deep link printed right after `gc sling` lands before the + // run's bead events exist (30-120s later). The loader must NOT surface + // failure after the fast delays (~4s) — it polls at the capped cadence + // (the server's pinned Retry-After: 5) until the run appears. + vi.useFakeTimers(); + const fetchMock = vi.fn(async () => + jsonResponse({ error: 'run view is warming', reason: 'unknown_run' }, 503), + ); + vi.stubGlobal('fetch', fetchMock); + + const pending = loadSupervisorFormulaRunDetail('mol-adopt-1'); + // The fast delays plus eleven capped 5s polls (~60s in, the earliest the + // controller's cache-reconcile usually surfaces a just-slung run)... + await vi.advanceTimersByTimeAsync(600 + 1_200 + 2_400 + 11 * 5_000); + expect(fetchMock).toHaveBeenCalledTimes(15); + + // ...then the run appears and the SAME load resolves. + fetchMock.mockResolvedValueOnce(jsonResponse(detailBody, 200)); + await vi.advanceTimersByTimeAsync(5_000); + await expect(pending).resolves.toMatchObject({ runId: 'mol-adopt-1' }); + }); + + it('gives up only after the ~180s warming budget is spent and surfaces the 503', async () => { vi.useFakeTimers(); const fetchMock = vi.fn(async () => jsonResponse({ error: 'run view is warming' }, 503)); vi.stubGlobal('fetch', fetchMock); const pending = loadSupervisorFormulaRunDetail('mol-adopt-1'); const assertion = expect(pending).rejects.toMatchObject({ status: 503 }); + await vi.advanceTimersByTimeAsync(180_000); + await assertion; + + // The initial attempt, the three fast retries (600+1200+2400 = 4.2s), then + // 5s-capped polls until the next delay would overrun the 180s budget: + // 1 + 3 + 35. + expect(fetchMock).toHaveBeenCalledTimes(39); + }); + + it('reports each warming 503 (with the graced unknown_run reason) to onWarming', async () => { + vi.useFakeTimers(); + const fetchMock = vi + .fn() + // A cold-replay warming 503 carries no reason; the graced unknown-run + // 503 carries reason 'unknown_run' (the pinned wire contract). + .mockResolvedValueOnce(jsonResponse({ error: 'run view is warming' }, 503)) + .mockResolvedValueOnce( + jsonResponse({ error: 'run view is warming', reason: 'unknown_run' }, 503), + ) + .mockResolvedValueOnce(jsonResponse(detailBody, 200)); + vi.stubGlobal('fetch', fetchMock); + const onWarming = vi.fn(); + + const pending = loadSupervisorFormulaRunDetail('mol-adopt-1', { onWarming }); + await vi.advanceTimersByTimeAsync(600 + 1_200); + + await expect(pending).resolves.toMatchObject({ runId: 'mol-adopt-1' }); + expect(onWarming.mock.calls.map(([warming]) => warming)).toEqual([ + { reason: undefined }, + { reason: 'unknown_run' }, + ]); + }); + + it('stops polling when keepPolling turns false and surfaces the pending 503', async () => { + // The caller (the hook) supersedes a poll on unmount/navigation/refresh; a + // superseded poll must stop issuing GETs instead of running out its 180s + // budget in the background. + vi.useFakeTimers(); + const fetchMock = vi.fn(async () => jsonResponse({ error: 'run view is warming' }, 503)); + vi.stubGlobal('fetch', fetchMock); + let polling = true; + + const pending = loadSupervisorFormulaRunDetail('mol-adopt-1', { + keepPolling: () => polling, + }); + const assertion = expect(pending).rejects.toMatchObject({ status: 503 }); + await vi.advanceTimersByTimeAsync(600); + polling = false; + await vi.advanceTimersByTimeAsync(1_200); + await assertion; + + // The initial attempt and the one retry that was already scheduled — the + // post-delay keepPolling check stops the third GET from ever firing. + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('keeps the short retry budget for a non-warming 5xx', async () => { + // Only the warming 503 gets the long poll; a persistent upstream 5xx is + // not a "run still being recorded" signal and surfaces after the fast + // delays as before. + vi.useFakeTimers(); + const fetchMock = vi.fn(async () => jsonResponse({ error: 'bad gateway' }, 502)); + vi.stubGlobal('fetch', fetchMock); + + const pending = loadSupervisorFormulaRunDetail('mol-adopt-1'); + const assertion = expect(pending).rejects.toMatchObject({ status: 502 }); await vi.advanceTimersByTimeAsync(600 + 1_200 + 2_400); await assertion; diff --git a/internal/api/dashboardspa/web/frontend/src/supervisor/runDetail.ts b/internal/api/dashboardspa/web/frontend/src/supervisor/runDetail.ts index 4b5af0d599..ed45c797d3 100644 --- a/internal/api/dashboardspa/web/frontend/src/supervisor/runDetail.ts +++ b/internal/api/dashboardspa/web/frontend/src/supervisor/runDetail.ts @@ -10,34 +10,103 @@ import { api, ApiClientError } from '../api/client'; // the projection derives a run's scope from its own root bead — though the // route still parses scope for the separate run-diff endpoint. -// Retry transient failures a few times before surfacing one: the BFF's 503 -// warming signal while a city's projection cold-replays (bounded server-side to -// ~5s), a 5xx upstream-proxy blip, or a network-level fetch reject. This -// restores the single-transient-retry resilience the pre-cutover supervisor -// read had (fetchCoreRead). A 4xx (404 unknown run, 422 unsupported) is -// definitive and surfaces immediately; SSE refresh and the manual Refresh +// A warming 503 is a poll signal, not a failure. The BFF answers 503 both +// while a city's projection cold-replays (usually clears within ~5s) and — the +// deep-link case — while a truly-unknown runId sits inside its post-sling +// grace window (rundetail_grace.go, 180s): a run slung from the CLI stays +// invisible to the projection until the controller's cache-reconcile emits its +// bead events, 30-120s later. So the loader polls: the fast delays first, then +// a capped cadence, for a total budget sized to the server's grace window. +// A non-503 transient failure (a 5xx upstream-proxy blip, a network-level +// fetch reject) gets ONLY the fast delays, restoring the pre-cutover +// single-transient-retry resilience. A 4xx (404 unknown run, 422 unsupported) +// is definitive and surfaces immediately; SSE refresh and the manual Refresh // button recover anything past the budget. const WARMING_RETRY_DELAYS_MS = [600, 1_200, 2_400]; +// The capped poll cadence once the fast delays are spent. Matches the +// Retry-After: 5 the BFF pins on the graced unknown-run 503; ApiClientError +// does not carry response headers, so the pinned value is encoded here rather +// than read per-response. +const WARMING_POLL_CAP_MS = 5_000; +// Total warming-poll budget, sized to the BFF's unknown-run grace window +// (unknownRunWarmingGrace = 180s). When the budget is spent the last 503 +// surfaces and the caller falls through to its failed state. +const WARMING_POLL_BUDGET_MS = 180_000; -export async function loadSupervisorFormulaRunDetail(runId: string): Promise { +/** + * A warming 503 observed while the loader polls. `reason` is the BFF's + * discriminator: 'unknown_run' when the projection is warm but has not seen + * this run yet (the just-slung grace window); undefined while the projection + * itself is still cold-replaying. + */ +export interface RunDetailWarming { + reason: string | undefined; +} + +/** Optional hooks into {@link loadSupervisorFormulaRunDetail}'s warming poll. */ +export interface LoadRunDetailOptions { + /** + * Called on each warming 503 before the next poll, so the caller can render + * honest interim copy (e.g. "this run may still be being recorded") instead + * of an anonymous spinner or a premature failure. + */ + onWarming?: (warming: RunDetailWarming) => void; + /** + * Polled between attempts; return false to stop (the caller navigated away + * or superseded this load with a fresh one). The pending error surfaces + * immediately and no further GET is issued. + */ + keepPolling?: () => boolean; +} + +/** + * Load a run's detail DTO from the BFF run-projection endpoint, polling + * through warming 503s (see the retry policy above) and retrying other + * transient failures a few times before surfacing one. + */ +export async function loadSupervisorFormulaRunDetail( + runId: string, + options?: LoadRunDetailOptions, +): Promise { + let elapsedMs = 0; for (let attempt = 0; ; attempt += 1) { try { return await api.runDetail(runId); } catch (err) { - const delayMs = WARMING_RETRY_DELAYS_MS[attempt]; - if (delayMs !== undefined && isTransientDetailError(err)) { - await delay(delayMs); - continue; - } - throw err; + const delayMs = retryDelayMs(err, attempt, elapsedMs); + if (delayMs === undefined || options?.keepPolling?.() === false) throw err; + if (isWarmingError(err)) options?.onWarming?.({ reason: err.reason }); + elapsedMs += delayMs; + await delay(delayMs); + // Re-check after the delay so a superseded poll stops BEFORE issuing + // another GET (the failure-time check above already let this attempt's + // delay be scheduled). + if (options?.keepPolling?.() === false) throw err; } } } -// A 4xx (404/422) is a definitive answer about the run — never retry it. The -// BFF's 503 warming signal and any 5xx are transient, as is a network-level -// fetch reject (a TypeError, e.g. "Failed to fetch"); a malformed-body decode -// error (ApiResponseDecodeError) is NOT transient and surfaces immediately. +// A warming 503 polls on the extended schedule: the fast delays, then the +// capped cadence, until the next delay would overrun the grace-window budget. +// Any other transient failure gets only the fast delays. Undefined = give up. +function retryDelayMs(err: unknown, attempt: number, elapsedMs: number): number | undefined { + if (isWarmingError(err)) { + const delayMs = WARMING_RETRY_DELAYS_MS[attempt] ?? WARMING_POLL_CAP_MS; + return elapsedMs + delayMs <= WARMING_POLL_BUDGET_MS ? delayMs : undefined; + } + return isTransientDetailError(err) ? WARMING_RETRY_DELAYS_MS[attempt] : undefined; +} + +// The BFF's warming signal: 503 while the projection cold-replays (no reason) +// or while an unknown run is inside its grace window (reason 'unknown_run'). +function isWarmingError(err: unknown): err is ApiClientError { + return err instanceof ApiClientError && err.status === 503; +} + +// A 4xx (404/422) is a definitive answer about the run — never retry it. A +// non-warming 5xx is transient, as is a network-level fetch reject (a +// TypeError, e.g. "Failed to fetch"); a malformed-body decode error +// (ApiResponseDecodeError) is NOT transient and surfaces immediately. function isTransientDetailError(err: unknown): boolean { if (err instanceof ApiClientError) return err.status >= 500; return err instanceof TypeError; diff --git a/internal/api/genclient/client_gen.go b/internal/api/genclient/client_gen.go index ca621b7066..7356bb50e5 100644 --- a/internal/api/genclient/client_gen.go +++ b/internal/api/genclient/client_gen.go @@ -3065,15 +3065,18 @@ type SlingInputBody struct { // SlingResponse defines model for SlingResponse. type SlingResponse struct { - AttachedBeadId *string `json:"attached_bead_id,omitempty"` - Bead *string `json:"bead,omitempty"` - Formula *string `json:"formula,omitempty"` - Mode *string `json:"mode,omitempty"` - RootBeadId *string `json:"root_bead_id,omitempty"` - Status string `json:"status"` - Target string `json:"target"` - Warnings *[]string `json:"warnings,omitempty"` - WorkflowId *string `json:"workflow_id,omitempty"` + AttachedBeadId *string `json:"attached_bead_id,omitempty"` + Bead *string `json:"bead,omitempty"` + + // DashboardUrl Absolute dashboard deep link for the slung work: the run detail view when a graph workflow was launched, otherwise the runs list. Present only when the serving process also hosts the dashboard (the supervisor listener); the standalone controller API omits it. + DashboardUrl *string `json:"dashboard_url,omitempty"` + Formula *string `json:"formula,omitempty"` + Mode *string `json:"mode,omitempty"` + RootBeadId *string `json:"root_bead_id,omitempty"` + Status string `json:"status"` + Target string `json:"target"` + Warnings *[]string `json:"warnings,omitempty"` + WorkflowId *string `json:"workflow_id,omitempty"` } // Status defines model for Status. diff --git a/internal/api/handler_sling.go b/internal/api/handler_sling.go index 85aca3349b..737807f006 100644 --- a/internal/api/handler_sling.go +++ b/internal/api/handler_sling.go @@ -46,6 +46,7 @@ type slingResponse struct { AttachedBeadID string `json:"attached_bead_id,omitempty"` Mode string `json:"mode,omitempty"` Warnings []string `json:"warnings,omitempty"` + DashboardURL string `json:"dashboard_url,omitempty" doc:"Absolute dashboard deep link for the slung work: the run detail view when a graph workflow was launched, otherwise the runs list. Present only when the serving process also hosts the dashboard (the supervisor listener); the standalone controller API omits it."` } var apiSlingStderr = func() io.Writer { return os.Stderr } diff --git a/internal/api/huma_handlers_sling.go b/internal/api/huma_handlers_sling.go index 282e8c780e..19841028a7 100644 --- a/internal/api/huma_handlers_sling.go +++ b/internal/api/huma_handlers_sling.go @@ -7,6 +7,7 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/gastownhall/gascity/internal/api/apierr" + "github.com/gastownhall/gascity/internal/api/dashboardbff" ) // SlingOutput is the Huma response for POST /v0/sling. @@ -124,8 +125,45 @@ func (s *Server) humaHandleSling(ctx context.Context, input *SlingInput) (*Sling return nil, apierr.InvalidRequest.Msg(message) } + // Successful sling: surface a dashboard deep link when this process also + // hosts the dashboard. This endpoint never produces batch shapes (no + // DoSlingBatch call), so resp.WorkflowID alone discriminates the single + // graph-workflow launch (run detail) from every other successful shape + // (wisps, plain bead routes, idempotent skips → runs list), matching the + // CLI's link policy. + resp.DashboardURL = s.slingDashboardURL(input.CityName, resp.WorkflowID) return &SlingOutput{ Status: status, Body: *resp, }, nil } + +// slingDashboardURL returns the dashboard deep link surfaced on a successful +// sling response, or "" when no link should be emitted. The dashboard SPA is +// mounted only on the supervisor listener (same-origin with this /v0 API); +// the standalone controller's [api] port serves /v0 without the SPA, so the +// link resolves only when the serving process installed a base via +// SupervisorMux.WithDashboardBase. Any resolution failure degrades silently +// to no link — the link is a convenience and must never fail the sling. +// +// cityName is the cityName path parameter (on the supervisor it is the +// registry name the dashboard routes by); a name outside the BFF grammar is +// dashboard-unreachable, so no link is minted for it. A non-empty workflowID +// (a graph.v2 run root) links to that run's detail view; every other +// successful shape links to the runs list. +func (s *Server) slingDashboardURL(cityName, workflowID string) string { + if s.dashboardBase == nil { + return "" + } + base := strings.TrimRight(strings.TrimSpace(s.dashboardBase()), "/") + if base == "" { + return "" + } + if !dashboardbff.ValidCityName(cityName) { + return "" + } + if workflowID != "" { + return base + dashboardbff.RunDetailPath(cityName, workflowID) + } + return base + dashboardbff.RunsListPath(cityName) +} diff --git a/internal/api/huma_handlers_sling_dashboard_test.go b/internal/api/huma_handlers_sling_dashboard_test.go new file mode 100644 index 0000000000..a6a6a59506 --- /dev/null +++ b/internal/api/huma_handlers_sling_dashboard_test.go @@ -0,0 +1,296 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gastownhall/gascity/internal/beads" + "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/formulatest" + "github.com/gastownhall/gascity/internal/molecule" +) + +// newSlingDashboardTestServer is newSlingTestServer plus a dashboard base +// injected on the per-city Server, mirroring what SupervisorMux. +// WithDashboardBase does on the production path. base == "" leaves the +// provider nil (the standalone-controller shape: no dashboard mounted). +func newSlingDashboardTestServer(t *testing.T, base string) (http.Handler, *fakeMutatorState) { + t.Helper() + state := newFakeMutatorState(t) + state.cfg.Rigs[0].Prefix = "gc" // match MemStore's auto-generated prefix + srv := New(state) + srv.SlingRunnerFunc = func(_ string, _ string, _ map[string]string) (string, error) { + return "", nil // no-op runner + } + if base != "" { + srv.dashboardBase = func() string { return base } + } + return newTestCityHandlerWith(t, state, srv), state +} + +func TestSlingDashboardURLResolver(t *testing.T) { + tests := []struct { + name string + base string // "" means nil provider + cityName string + workflowID string + want string + }{ + { + name: "nil provider omits link", + base: "", + cityName: "test-city", + want: "", + }, + { + name: "workflow id links to run detail", + base: "http://127.0.0.1:8372", + cityName: "test-city", + workflowID: "gcg-run-1", + want: "http://127.0.0.1:8372/city/test-city/runs/gcg-run-1", + }, + { + name: "no workflow id links to runs list", + base: "http://127.0.0.1:8372", + cityName: "test-city", + want: "http://127.0.0.1:8372/city/test-city/runs", + }, + { + name: "trailing slash on base is trimmed", + base: "http://127.0.0.1:8372/", + cityName: "test-city", + workflowID: "gcg-run-1", + want: "http://127.0.0.1:8372/city/test-city/runs/gcg-run-1", + }, + { + name: "city name outside BFF grammar omits link", + base: "http://127.0.0.1:8372", + cityName: "bright.lights", + workflowID: "gcg-run-1", + want: "", + }, + { + name: "workflow id is path escaped", + base: "http://127.0.0.1:8372", + cityName: "test-city", + workflowID: "gcg/run 1", + want: "http://127.0.0.1:8372/city/test-city/runs/gcg%2Frun%201", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := New(newFakeMutatorState(t)) + if tt.base != "" { + srv.dashboardBase = func() string { return tt.base } + } + if got := srv.slingDashboardURL(tt.cityName, tt.workflowID); got != tt.want { + t.Fatalf("slingDashboardURL(%q, %q) = %q, want %q", tt.cityName, tt.workflowID, got, tt.want) + } + }) + } +} + +func TestSlingDashboardURLResolverEmptyBase(t *testing.T) { + srv := New(newFakeMutatorState(t)) + srv.dashboardBase = func() string { return "" } + if got := srv.slingDashboardURL("test-city", "gcg-run-1"); got != "" { + t.Fatalf("slingDashboardURL with empty base = %q, want empty", got) + } +} + +func TestWithDashboardBasePropagatesToCityServers(t *testing.T) { + state := newFakeMutatorState(t) + sm := NewSupervisorMux(&stateCityResolver{state: state}, nil, false, "test", "", time.Now()) + sm.WithDashboardBase(func() string { return "http://127.0.0.1:8372" }) + + srv := sm.getCityServer(state.CityName(), state) + if srv.dashboardBase == nil { + t.Fatal("dashboardBase = nil, want provider propagated from WithDashboardBase") + } + if got := srv.dashboardBase(); got != "http://127.0.0.1:8372" { + t.Fatalf("dashboardBase() = %q, want http://127.0.0.1:8372", got) + } +} + +func TestCityServersDefaultToNoDashboardBase(t *testing.T) { + state := newFakeMutatorState(t) + sm := NewSupervisorMux(&stateCityResolver{state: state}, nil, false, "test", "", time.Now()) + + srv := sm.getCityServer(state.CityName(), state) + if srv.dashboardBase != nil { + t.Fatal("dashboardBase != nil, want unset on a mux without WithDashboardBase") + } +} + +func TestWithDashboardBaseNilIsNoOp(t *testing.T) { + state := newFakeMutatorState(t) + sm := NewSupervisorMux(&stateCityResolver{state: state}, nil, false, "test", "", time.Now()) + sm.WithDashboardBase(nil) + + srv := sm.getCityServer(state.CityName(), state) + if srv.dashboardBase != nil { + t.Fatal("dashboardBase != nil, want nil provider ignored") + } +} + +func TestSlingResponseDashboardURLRunsListForDirectRoute(t *testing.T) { + h, state := newSlingDashboardTestServer(t, "http://127.0.0.1:8372/") + store := state.stores["myrig"] + b, err := store.Create(beads.Bead{Title: "test task", Type: "task"}) + if err != nil { + t.Fatal(err) + } + + body := `{"target":"myrig/worker","bead":"` + b.ID + `"}` + rec := httptest.NewRecorder() + h.ServeHTTP(rec, newPostRequest(cityURL(state, "/sling"), strings.NewReader(body))) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String()) + } + var resp struct { + Status string `json:"status"` + DashboardURL string `json:"dashboard_url"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Status != "slung" { + t.Fatalf("status = %q, want slung", resp.Status) + } + if want := "http://127.0.0.1:8372/city/test-city/runs"; resp.DashboardURL != want { + t.Fatalf("dashboard_url = %q, want %q (runs list for a non-workflow sling)", resp.DashboardURL, want) + } +} + +func TestSlingResponseDashboardURLRunDetailForGraphLaunch(t *testing.T) { + // Same compile-time flag choreography as + // TestSlingGraphV2RejectsLegacySourceWorkflowConflict: flip the shared + // FormulaV2 + graph-apply flags only after New() has run so + // syncFeatureFlags cannot stomp them back. + setFormulaV2 := formulatest.LockV2ForTest(t) + prevGraphApply := molecule.IsGraphApplyEnabled() + t.Cleanup(func() { + molecule.SetGraphApplyEnabled(prevGraphApply) + }) + + h, state := newSlingDashboardTestServer(t, "http://127.0.0.1:8372") + setFormulaV2(true) + molecule.SetGraphApplyEnabled(true) + formulaDir := t.TempDir() + state.cfg.FormulaLayers.City = []string{formulaDir} + state.cfg.Agents = append(state.cfg.Agents, + config.Agent{Name: config.ControlDispatcherAgentName, MaxActiveSessions: intPtr(1)}, + config.Agent{Name: config.ControlDispatcherAgentName, Dir: "myrig", MaxActiveSessions: intPtr(1)}, + ) + if err := os.WriteFile(filepath.Join(formulaDir, "graph-work.toml"), []byte(` +formula = "graph-work" +version = 2 +contract = "graph.v2" + +[[steps]] +id = "step" +title = "Do work" +`), 0o644); err != nil { + t.Fatal(err) + } + store := state.stores["myrig"] + source, err := store.Create(beads.Bead{ID: "BL-42", Title: "test task", Type: "task", Status: "open"}) + if err != nil { + t.Fatal(err) + } + + body := `{"target":"myrig/worker","formula":"graph-work","attached_bead_id":"` + source.ID + `"}` + rec := httptest.NewRecorder() + h.ServeHTTP(rec, newPostRequest(cityURL(state, "/sling"), strings.NewReader(body))) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String()) + } + var resp struct { + WorkflowID string `json:"workflow_id"` + DashboardURL string `json:"dashboard_url"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.WorkflowID == "" { + t.Fatal("workflow_id empty, want graph.v2 launch to mint a run root") + } + if want := "http://127.0.0.1:8372/city/test-city/runs/" + resp.WorkflowID; resp.DashboardURL != want { + t.Fatalf("dashboard_url = %q, want %q (run detail for a workflow launch)", resp.DashboardURL, want) + } +} + +func TestSlingResponseOmitsDashboardURLWhenUnmounted(t *testing.T) { + h, state := newSlingDashboardTestServer(t, "") + store := state.stores["myrig"] + b, err := store.Create(beads.Bead{Title: "test task", Type: "task"}) + if err != nil { + t.Fatal(err) + } + + body := `{"target":"myrig/worker","bead":"` + b.ID + `"}` + rec := httptest.NewRecorder() + h.ServeHTTP(rec, newPostRequest(cityURL(state, "/sling"), strings.NewReader(body))) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "dashboard_url") { + t.Fatalf("body = %s, want dashboard_url omitted when no dashboard is mounted", rec.Body.String()) + } +} + +func TestSlingResponseOmitsDashboardURLForUnservableCityName(t *testing.T) { + // The supervisor registry grammar accepts names (e.g. with dots) that + // the dashboard BFF grammar rejects; such cities are + // dashboard-unreachable so the link must be omitted, not minted dead. + state := newFakeMutatorState(t) + state.cityName = "bright.lights" + state.cfg.Rigs[0].Prefix = "gc" + srv := New(state) + srv.SlingRunnerFunc = func(_ string, _ string, _ map[string]string) (string, error) { + return "", nil + } + srv.dashboardBase = func() string { return "http://127.0.0.1:8372" } + h := newTestCityHandlerWith(t, state, srv) + + store := state.stores["myrig"] + b, err := store.Create(beads.Bead{Title: "test task", Type: "task"}) + if err != nil { + t.Fatal(err) + } + + body := `{"target":"myrig/worker","bead":"` + b.ID + `"}` + rec := httptest.NewRecorder() + h.ServeHTTP(rec, newPostRequest(cityURL(state, "/sling"), strings.NewReader(body))) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "dashboard_url") { + t.Fatalf("body = %s, want dashboard_url omitted for a BFF-unservable city name", rec.Body.String()) + } +} + +func TestSlingFailureResponseHasNoDashboardURL(t *testing.T) { + h, state := newSlingDashboardTestServer(t, "http://127.0.0.1:8372") + + body := `{"target":"myrig/worker","bead":"gc-does-not-exist"}` + rec := httptest.NewRecorder() + h.ServeHTTP(rec, newPostRequest(cityURL(state, "/sling"), strings.NewReader(body))) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "dashboard_url") { + t.Fatalf("body = %s, want no dashboard_url on a failed sling", rec.Body.String()) + } +} diff --git a/internal/api/openapi.json b/internal/api/openapi.json index ddd26ef171..c907bdf6f5 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -7390,6 +7390,10 @@ "bead": { "type": "string" }, + "dashboard_url": { + "description": "Absolute dashboard deep link for the slung work: the run detail view when a graph workflow was launched, otherwise the runs list. Present only when the serving process also hosts the dashboard (the supervisor listener); the standalone controller API omits it.", + "type": "string" + }, "formula": { "type": "string" }, diff --git a/internal/api/server.go b/internal/api/server.go index d98a910812..459a899745 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -94,6 +94,14 @@ type Server struct { componentVersionsValue componentVersions componentVersionsProbe func() componentVersions + // dashboardBase reports the browser-reachable base URL of the dashboard + // mounted on the process serving this city's API, or "" when unmounted. + // Nil (the default) also means unmounted — the standalone controller + // [api] port serves /v0 without the SPA — so handlers omit dashboard + // deep links. Populated from SupervisorMux.WithDashboardBase when the + // supervisor builds per-city servers. + dashboardBase func() string + // LookPathFunc can be overridden in tests. Defaults to exec.LookPath. LookPathFunc func(string) (string, error) diff --git a/internal/api/supervisor.go b/internal/api/supervisor.go index 48ed5e61f5..e587de1162 100644 --- a/internal/api/supervisor.go +++ b/internal/api/supervisor.go @@ -117,6 +117,7 @@ type SupervisorMux struct { allowAnyHost bool writeAuth *citywriteauth.Verifier readAuth *citywriteauth.Verifier + dashboardBase func() string server *http.Server // Single Huma API (Phase 3.5 — Topology 1). Owns every typed @@ -294,6 +295,36 @@ func (sm *SupervisorMux) WithAPIPlane(h http.Handler) *SupervisorMux { return sm } +// WithDashboardBase records where the embedded dashboard is served so +// per-city handlers can mint dashboard deep links (e.g. the sling response's +// dashboard_url). The provider returns the browser-reachable base URL of THIS +// listener (scheme://host:port; a trailing slash is tolerated), or "" when no +// link should be emitted. Leave unset on API-only processes — the standalone +// controller's [api] port serves /v0 without the SPA — so responses omit +// dashboard links. Callers must also leave it unset on wildcard binds +// (0.0.0.0, ::): there is no single static origin that is browser-reachable +// for every /v0 caller, and deriving one from request Host headers would +// trust a spoofable value, so responses omit dashboard_url instead. Must be +// called before Serve. Passing nil is a no-op. +func (sm *SupervisorMux) WithDashboardBase(provider func() string) *SupervisorMux { + if provider == nil { + return sm + } + sm.dashboardBase = provider + return sm +} + +// DashboardBaseURL returns the dashboard base URL installed via +// WithDashboardBase, or "" when none is set. Exposed so wiring tests can +// assert the dashboard-attach path installed a link base without reaching +// into unexported state. +func (sm *SupervisorMux) DashboardBaseURL() string { + if sm.dashboardBase == nil { + return "" + } + return sm.dashboardBase() +} + // WithWriteAuth installs the write-auth verifier so city-scoped mutations are // gated on a signed grant, and rebuilds the internal http.Server handler. A nil // verifier leaves write-auth disabled. Must be called before Serve. @@ -424,6 +455,11 @@ func (sm *SupervisorMux) getCityServer(name string, state State) *Server { if sm.readOnly { srv = NewReadOnly(state) } + // Thread the dashboard link base (if the dashboard is mounted on this + // process) into the per-city handler host. WithDashboardBase runs before + // Serve, and per-city servers are built lazily per request, so every + // cached server observes the final provider. + srv.dashboardBase = sm.dashboardBase sm.cacheMu.Lock() sm.cache[name] = cachedCityServer{state: state, srv: srv} diff --git a/internal/runproj/detail.go b/internal/runproj/detail.go index 07ec44d8d0..3e2fb21c77 100644 --- a/internal/runproj/detail.go +++ b/internal/runproj/detail.go @@ -1,6 +1,7 @@ package runproj import ( + "errors" "fmt" "strings" "sync/atomic" @@ -9,6 +10,14 @@ import ( "github.com/gastownhall/gascity/internal/beads" ) +// ErrRunNotFound is the sentinel wrapped by SnapshotForRun (and everything +// built on it) when the requested run root is absent from the folded beads — +// the run is truly unknown to the projection, as opposed to present but +// unprojectable (UnsupportedRunError). Callers branch on it with errors.Is; +// the dashboard BFF uses it to grant a just-slung run's deep link a warming +// grace window instead of a terminal 404. +var ErrRunNotFound = errors.New("run not found") + // snapshotScanCount counts every snapshotForRun invocation. It exists so a test // can prove the single-scan entry points fold a run exactly once (the detail // path used to scan twice — once for the formula target, once for the build). @@ -30,7 +39,7 @@ type RunSnapshot struct { // BuildRunDetailFromSnapshot consume. version and eventSeq parameterize the // snapshot identity (the golden passes 1/100; the live tailer passes a real // version and its LastSeq cursor). It returns an error only when the run root is -// absent from beadList. +// absent from beadList; that error wraps ErrRunNotFound. func SnapshotForRun(beadList []beads.Bead, runID string, version int, eventSeq int64) (RunSnapshot, error) { raw, err := snapshotForRun(beadList, runID, version, eventSeq) if err != nil { @@ -218,7 +227,7 @@ func snapshotForRun(beadList []beads.Bead, rootID string, version int, eventSeq } } if rootIdx < 0 { - return runSnapshot{}, fmt.Errorf("runproj: detail run root %q not found", rootID) + return runSnapshot{}, fmt.Errorf("runproj: detail run root %q: %w", rootID, ErrRunNotFound) } root := beadList[rootIdx] diff --git a/internal/runproj/detail_snapshot_test.go b/internal/runproj/detail_snapshot_test.go index f77575380e..314a373a6d 100644 --- a/internal/runproj/detail_snapshot_test.go +++ b/internal/runproj/detail_snapshot_test.go @@ -3,6 +3,7 @@ package runproj import ( "bytes" "encoding/json" + "errors" "os" "path/filepath" "testing" @@ -10,6 +11,21 @@ import ( "github.com/gastownhall/gascity/internal/beads" ) +// TestSnapshotForRunMissingRootIsErrRunNotFound proves the missing-root failure +// carries the ErrRunNotFound sentinel, so the dashboard BFF can distinguish a +// truly-unknown run (eligible for its unknown-run warming grace) from every +// other projection failure. +func TestSnapshotForRunMissingRootIsErrRunNotFound(t *testing.T) { + beadList := loadDetailFixture(t) + _, err := SnapshotForRun(beadList, "no-such-run", detailGoldenSnapshotVersion, detailGoldenSnapshotEventSeq) + if err == nil { + t.Fatal("SnapshotForRun with an absent root returned nil error") + } + if !errors.Is(err, ErrRunNotFound) { + t.Fatalf("err = %v, want errors.Is(err, ErrRunNotFound)", err) + } +} + // loadDetailFixture reads the shared bead fixture used by the golden tests. func loadDetailFixture(t *testing.T) []beads.Bead { t.Helper() diff --git a/schemas/sling/result.schema.json b/schemas/sling/result.schema.json index 7a7e354463..e6ae66530a 100644 --- a/schemas/sling/result.schema.json +++ b/schemas/sling/result.schema.json @@ -65,6 +65,10 @@ "type": "boolean", "description": "Whether the command ran without mutating dispatch state." }, + "dashboard_url": { + "type": "string", + "description": "Absolute dashboard deep link for the slung work, present only when the supervisor dashboard is reachable." + }, "warnings": { "type": "array", "description": "Non-fatal warnings from dispatch.", From 05039d39971c9c5ffeb4e1d190c59855f969a2d5 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Fri, 10 Jul 2026 17:16:10 +0000 Subject: [PATCH 046/225] =?UTF-8?q?feat(rollout):=20prompt-boundary=20lint?= =?UTF-8?q?=20=E2=80=94=20flag=20values=20can't=20reach=20prompt=20content?= =?UTF-8?q?=20(PR-1a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete Stage 1 of the internal/rollout feature-flag subsystem: the structural guarantee that a rollout gate's VALUE can never flow into agent-visible prompt template data. Rollout gates select mechanical Go transport; a flag reaching a prompt would violate gascity's "no capability flags — a sentence in the prompt is sufficient" principle. This is execution-plan task S1-T14. A Fable design pass took DESIGN open-question-5's sanctioned "defer the internal/prompt extraction, rely on the AST lint" path: the extraction is ~8 production files + ~20 exports + ~1600 test lines with identical threat coverage and is non-blocking, so it is deferred to ga-b1ii8y (post-S3) and this ships the lint instead — zero production change. The lint (cmd/gc/prompt_rollout_boundary_test.go) was hardened by two adversarial Fable red-team passes. The first defeated a seam-matching version (matching only .Env writes and named FuncMap literals leaked non-Env PromptContext field writes, unnamed map[string]any funcmaps, and buildTemplateData-result writes, and it FALSE-flagged mechanical flag transport in systemd-unit FuncMaps and subprocess env). The second defeated a broad ident ban. The final design: - Scope to the AST-DERIVED cmd/gc render-file set (files referencing PromptContext/renderPrompt/renderPromptWithMeta/buildTemplateData/promptFuncMap), anti-vacuity-pinned and per-anchor-liveness-checked. PromptContext and the render entry points are unexported in package main, so this scope is where all prompt construction lives. - THE RULE: within a render file, a flag-value identifier is forbidden EVERYWHERE EXCEPT in a control-flow condition. Reading a gate to branch (gc prime does) is fine; assigning it to a PromptContext field, returning it from a funcmap closure, writing it into template data, or laundering it as a call argument inside a condition is not. One rule, every seam. - The forbidden set is registry-derived: the latch field/accessor plus, per registered gate, its backing config field and every internal/config function whose RETURN statement yields that field (fixpoint, so wrappers/City-level accessors/plain functions of any name are caught; return-scoped, so decoders like Parse that merely touch the field are not). Adding a gate extends the guard with no test edits. This also subsumes the PR-1c raw-config→prompt addendum. - TestPromptBoundaryCheckerHasTeeth pins the rule on synthetic AST (if-body, call-arg-in-condition, and value-position cases) so a neutering mutation fails. Honest residue, documented and review-governed per DESIGN §2.4 (all closed by the ga-b1ii8y extraction, which makes the import edge compiler-enforced): a value laundered through an intermediate in a non-render file, a package-internal PromptContext type-alias in an anchor-free file, or a side-effecting condition helper. This is a defense-in-depth tripwire for the realistic accidental leak, not an absolute proof — exactly what the design promises. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/gc/prompt_rollout_boundary_test.go | 486 ++++++++++++++++++ engdocs/plans/feature-flags/EXECUTION-PLAN.md | 1 + 2 files changed, 487 insertions(+) create mode 100644 cmd/gc/prompt_rollout_boundary_test.go diff --git a/cmd/gc/prompt_rollout_boundary_test.go b/cmd/gc/prompt_rollout_boundary_test.go new file mode 100644 index 0000000000..2120df7505 --- /dev/null +++ b/cmd/gc/prompt_rollout_boundary_test.go @@ -0,0 +1,486 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + + "github.com/gastownhall/gascity/internal/config" + "github.com/gastownhall/gascity/internal/rollout" +) + +// This file is PR-1a: the structural guarantee that feature-flag values (from +// internal/rollout) can NEVER flow into agent-visible prompt content. Rollout +// gates select MECHANICAL Go transport paths; a smarter model obviates +// agent-behavior toggles, so a flag VALUE reaching a prompt template would +// violate gascity's "no capability flags — a sentence in the prompt is +// sufficient" principle. It is the sanctioned lighter form of execution-plan +// task S1-T14 (DESIGN open-question-5's "defer the internal/prompt extraction, +// rely on the AST lint" fallback); the full extraction is deferred to ga-b1ii8y. +// +// SCOPE + HONEST LIMITS. All prompt construction lives in cmd/gc package main +// (PromptContext, renderPrompt, buildTemplateData, promptFuncMap are unexported), +// so the guard scopes to the AST-DERIVED set of cmd/gc render files. This is a +// DEFENSE-IN-DEPTH tripwire for DIRECT flows, NOT an absolute proof: a purely +// syntactic lint cannot chase every value-laundering path (DESIGN §2.4 says so +// and makes the value-flow half review-governed). It reliably catches the +// realistic, accidental leak — `ctx.Field = flags.Mode()` / a config accessor's +// value assigned or returned into template data — and its self-protection is +// pinned by TestPromptBoundaryCheckerHasTeeth. The STRUCTURAL fix that closes the +// residue classes below is ga-b1ii8y (extract internal/prompt so the import edge +// is compiler-enforced). +// +// THE RULE (learned from an adversarial red-team that defeated an earlier +// seam-by-seam matcher). Seam matching — "no flag ident inside a PromptContext +// literal / .Env write / named FuncMap" — leaks: a value reaches template data +// through a non-Env field assignment (ctx.WorkQuery = ...), an unnamed +// map[string]any passed to .Funcs, a buildTemplateData-result write (td[k] = +// ...), or an intermediate variable, and it also FALSE-flags mechanical flag +// transport elsewhere (a systemd-unit FuncMap, a subprocess Env). Instead: +// within a render file, a flag-value identifier is forbidden EVERYWHERE EXCEPT in +// a control-flow condition (if/for/switch). Reading a gate to BRANCH is +// legitimate (gc prime does); assigning or returning its VALUE — the only way it +// reaches template data — is not. One rule, every seam. +// +// KNOWN RESIDUE — review-governed (registry SelectsBetween litmus + CODEOWNERS on +// registry.go), and all closed by the ga-b1ii8y extraction: +// - a value laundered through an intermediate variable OR a helper that lives in +// a NON-render file (the file references no anchor, so it is not scanned); +// - a value written to prompt data through a package-internal type ALIAS or an +// embedding of PromptContext in a file that names no anchor ident (file-level +// derivation is not closed under aliasing); +// - a value carried by a side-effecting helper whose call sits in a condition +// but whose body writes prompt data (only the argument laundering — a +// forbidden ident passed as a call arg inside a condition — is caught here). +// The condition allowance is narrow: a gate read is legitimate ONLY in the +// condition expression itself (`if gate() {}`), NOT in an `if x := gate(); ...` +// init clause (which could leak x into the body) and NOT as an argument to +// another call in the condition. + +const rolloutPkgPath = "github.com/gastownhall/gascity/internal/rollout" + +// promptRenderAnchors are the identifiers whose presence marks a file as part of +// the prompt-render path. The render set is derived from these (not hardcoded), +// so a new render call site auto-joins the guarded set. +var promptRenderAnchors = []string{ + "PromptContext", "renderPrompt", "renderPromptWithMeta", "buildTemplateData", "promptFuncMap", +} + +// pinnedRenderFiles must always appear in the derived render set; their absence +// means the derivation broke (anti-vacuity). +var pinnedRenderFiles = []string{"prompt.go", "template_resolve.go", "cmd_prime.go", "cmd_lint.go"} + +// flagValuePin names an accessor the derivation MUST rediscover per gate, so a +// config-side rename fails loudly instead of silently shrinking the guard. +var flagValuePins = map[string]string{ + "beads.conditional_writes": "NormalizedConditionalWrites", + "daemon.formula_v2": "FormulaV2Enabled", +} + +// TestPromptRenderFilesGateRolloutFlags is the guarantee: no cmd/gc render file +// imports internal/rollout, and no flag-value identifier appears in a render file +// outside a control-flow condition. +func TestPromptRenderFilesGateRolloutFlags(t *testing.T) { + fset := token.NewFileSet() + files := parseNonTestGoFiles(t, fset, cmdGCDir(t)) + + render := map[string]*ast.File{} + for name, f := range files { + if fileReferencesAnyIdent(f, promptRenderAnchors...) { + render[name] = f + } + } + for _, want := range pinnedRenderFiles { + if _, ok := render[want]; !ok { + t.Fatalf("render-file derivation missed %s — the anchor set or scan is broken (anti-vacuity)", want) + } + } + // Each anchor must be referenced by at least one file, else a dropped or + // renamed anchor silently shrinks the derivation. + for _, anchor := range promptRenderAnchors { + live := false + for _, f := range files { + if fileReferencesAnyIdent(f, anchor) { + live = true + break + } + } + if !live { + t.Fatalf("render anchor %q matches no cmd/gc non-test file — dead anchor or renamed helper (anti-vacuity)", anchor) + } + } + + forbidden := promptFlagValueIdents(t) + for name, f := range render { + for _, imp := range f.Imports { + if strings.Trim(imp.Path.Value, `"`) == rolloutPkgPath { + t.Errorf("%s renders prompts and imports %s; a render file must never reach the rollout subsystem", name, rolloutPkgPath) + } + } + for _, v := range flagIdentsOutsideConditions(f, forbidden) { + t.Errorf("%s: %q reaches prompt rendering outside a control-flow condition — %s; a rollout gate's VALUE must never flow into prompt content (reading it to branch is fine, assigning/returning it is not)", name, v.name, v.why) + } + } +} + +// TestPromptBoundaryCheckerHasTeeth pins the core rule against neutering, +// independent of the production tree. A pure condition read is allowed; a value +// use (assignment, map write, closure return, or a value laundered as a call +// argument inside a condition, or a write inside an if-body) is flagged. Mutations +// that neuter the walk (mark(x.Cond)->mark(x), dropping the call-arg distinction, +// or making it vacuous) change this count and fail. +func TestPromptBoundaryCheckerHasTeeth(t *testing.T) { + const src = `package p +type ctxT struct{ WorkQuery string } +func f(c *ctxT, b beadsT, td map[string]string) { + if b.NormalizedConditionalWrites() != "" { // ALLOWED: pure condition read + c.WorkQuery = b.NormalizedConditionalWrites() // banned: value in an if-body + } + c.WorkQuery = b.NormalizedConditionalWrites() // banned: value into a prompt field + td["cas"] = b.NormalizedConditionalWrites() // banned: value into template-data map + _ = func() string { return b.NormalizedConditionalWrites() } // banned: value out of a funcmap-shaped closure + if stash(c, b.NormalizedConditionalWrites()) { // banned: value laundered as a call argument in a condition + } +} +func stash(c *ctxT, v string) bool { c.WorkQuery = v; return true } +` + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "teeth.go", src, 0) + if err != nil { + t.Fatal(err) + } + forbidden := map[string]string{"NormalizedConditionalWrites": "test accessor"} + got := flagIdentsOutsideConditions(f, forbidden) + if len(got) != 5 { + t.Fatalf("checker teeth: want exactly 5 value-position violations (if-body, assignment, map write, closure return, call-arg-in-condition) and 0 for the pure condition read, got %d: %+v", len(got), got) + } +} + +// promptFlagValueIdents returns identifier name -> reason for every symbol that +// carries a rollout gate's value: the controller's latch field/accessor, each +// registered gate's backing config field, and — DERIVED BY PARSING METHOD BODIES, +// not by name convention — every config accessor that reads that field. Any +// accessor reading the gate field is caught regardless of its name; a pin per +// known gate turns a config refactor that loses the accessor into a loud failure. +func promptFlagValueIdents(t *testing.T) map[string]string { + t.Helper() + forbidden := map[string]string{ + "rolloutFlags": "the controller's boot-latched rollout.Flags field", + "RolloutFlags": "the State rollout-flags accessor", + } + configDir := filepath.Join(repoRoot(t), "internal", "config") + for _, s := range rollout.Specs() { + leaf, _, ok := configLeafField(s.ConfigPath) + if !ok { + t.Fatalf("spec %s: ConfigPath %q did not resolve against config.City", s.Key, s.ConfigPath) + } + forbidden[leaf] = "the config field backing gate " + s.Key + for _, name := range configFlagAccessors(t, configDir, leaf) { + forbidden[name] = "a config accessor for gate " + s.Key + } + if want, has := flagValuePins[s.Key]; has { + if _, ok := forbidden[want]; !ok { + t.Fatalf("gate %s: accessor derivation lost %q — a config refactor must update flagValuePins so the guard is re-reviewed (anti-vacuity)", s.Key, want) + } + } + } + // Pin the hardcoded latch idents so deleting them fails loudly (they are not + // registry-derived, so nothing else covers them). + for _, want := range []string{"rolloutFlags", "RolloutFlags"} { + if _, ok := forbidden[want]; !ok { + t.Fatalf("forbidden set lost the latch ident %q (anti-vacuity)", want) + } + } + return forbidden +} + +// configLeafField walks config.City by dotted toml path and returns the leaf +// struct field's Go name and its owning struct type. +func configLeafField(path string) (leaf string, owner reflect.Type, ok bool) { + t := reflect.TypeOf(config.City{}) + segs := strings.Split(path, ".") + for i, seg := range segs { + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return "", nil, false + } + f, found := fieldByTOMLTag(t, seg) + if !found { + return "", nil, false + } + if i == len(segs)-1 { + return f.Name, t, true + } + t = f.Type + } + return "", nil, false +} + +func fieldByTOMLTag(t reflect.Type, name string) (reflect.StructField, bool) { + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + tag := f.Tag.Get("toml") + if tag == "" { + continue + } + if strings.Split(tag, ",")[0] == name { + return f, true + } + } + return reflect.StructField{}, false +} + +// configFlagAccessors parses internal/config and returns the EXPORTED functions +// whose body reads the gate field `leaf` — directly (a `.leaf` selector, on ANY +// receiver or a plain function) or TRANSITIVELY (calls another accessor already +// in the set). Fixpoint over all functions (including unexported links in a +// chain), so an accessor named anything (CASWriteMode), one on a wrapping type +// (City reading .Beads.ConditionalWrites), a plain function, or a wrapper that +// only calls another accessor are all caught. Only exported names are returned, +// since a render file (a different package) can reference only those. +func configFlagAccessors(t *testing.T, configDir, leaf string) []string { + t.Helper() + type fn struct { + name string + body *ast.BlockStmt + exported bool + } + var fns []fn + entries, err := os.ReadDir(configDir) + if err != nil { + t.Fatalf("ReadDir(%q): %v", configDir, err) + } + fset := token.NewFileSet() + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + f, err := parser.ParseFile(fset, filepath.Join(configDir, name), nil, 0) + if err != nil { + continue + } + for _, decl := range f.Decls { + if fd, ok := decl.(*ast.FuncDecl); ok && fd.Body != nil { + fns = append(fns, fn{fd.Name.Name, fd.Body, fd.Name.IsExported()}) + } + } + } + + inSet := map[string]bool{} + for changed := true; changed; { + changed = false + for _, f := range fns { + if inSet[f.name] { + continue + } + if returnReadsFieldOrAccessor(f.body, leaf, inSet) { + inSet[f.name] = true + changed = true + } + } + } + + var out []string + for _, f := range fns { + if inSet[f.name] && f.exported { + out = append(out, f.name) + } + } + return out +} + +// returnReadsFieldOrAccessor reports whether any RETURN statement in body yields +// the gate field `leaf` (a `.leaf` selector) or a name already in `known` (a +// discovered accessor). Return-scoped, not whole-body: a true accessor RETURNS +// the field value, whereas a decoder/validator (Parse, LoadPack) merely touches +// the field while returning something else — the latter must not be forbidden, +// since render files legitimately call them. +func returnReadsFieldOrAccessor(body *ast.BlockStmt, leaf string, known map[string]bool) bool { + found := false + ast.Inspect(body, func(n ast.Node) bool { + ret, ok := n.(*ast.ReturnStmt) + if !ok { + return true + } + for _, r := range ret.Results { + ast.Inspect(r, func(m ast.Node) bool { + switch x := m.(type) { + case *ast.SelectorExpr: + if x.Sel.Name == leaf || known[x.Sel.Name] { + found = true + return false + } + case *ast.Ident: + if known[x.Name] { + found = true + return false + } + } + return true + }) + } + return true + }) + return found +} + +type flagViolation struct { + name string + why string +} + +// flagIdentsOutsideConditions returns every forbidden identifier in f that is NOT +// inside a control-flow condition (an if/for condition, a switch tag, or a case +// expression). Idents in those positions are legitimate control-flow reads; every +// other position — assignments, returns, call arguments, composite literals — is a +// value use that could carry the flag into prompt data. +func flagIdentsOutsideConditions(f *ast.File, forbidden map[string]string) []flagViolation { + allowed := map[*ast.Ident]bool{} + ast.Inspect(f, func(n ast.Node) bool { + switch x := n.(type) { + case *ast.IfStmt: + markConditionReads(x.Cond, allowed) + case *ast.ForStmt: + markConditionReads(x.Cond, allowed) + case *ast.SwitchStmt: + markConditionReads(x.Tag, allowed) + case *ast.CaseClause: + for _, e := range x.List { + markConditionReads(e, allowed) + } + } + return true + }) + + var out []flagViolation + ast.Inspect(f, func(n ast.Node) bool { + if id, ok := n.(*ast.Ident); ok && !allowed[id] { + if why, bad := forbidden[id.Name]; bad { + out = append(out, flagViolation{name: id.Name, why: why}) + } + } + return true + }) + return out +} + +// markConditionReads marks identifiers in a control-flow condition as allowed +// reads — but ONLY those that feed the condition's own boolean/comparison logic, +// NOT those passed as ARGUMENTS to a call (`if stash(ctx, gate())` launders +// gate()'s value into stash's side effects, so gate() there is a value use, not a +// branch read). It threads an inArg flag through the expression tree; unhandled +// shapes mark nothing (conservative — a forbidden ident there is flagged). +func markConditionReads(cond ast.Node, allowed map[*ast.Ident]bool) { + var walk func(n ast.Node, inArg bool) + walk = func(n ast.Node, inArg bool) { + switch x := n.(type) { + case nil: + return + case *ast.Ident: + if !inArg { + allowed[x] = true + } + case *ast.SelectorExpr: + walk(x.X, inArg) + walk(x.Sel, inArg) + case *ast.CallExpr: + walk(x.Fun, inArg) + for _, a := range x.Args { + walk(a, true) + } + case *ast.BinaryExpr: + walk(x.X, inArg) + walk(x.Y, inArg) + case *ast.UnaryExpr: + walk(x.X, inArg) + case *ast.ParenExpr: + walk(x.X, inArg) + case *ast.StarExpr: + walk(x.X, inArg) + case *ast.IndexExpr: + walk(x.X, inArg) + walk(x.Index, true) + case *ast.IndexListExpr: + walk(x.X, inArg) + for _, i := range x.Indices { + walk(i, true) + } + case *ast.BasicLit: + // literal — nothing to mark + default: + // Unhandled expression shape: mark nothing, so a forbidden ident here + // is reported rather than silently allowed. + } + } + walk(cond, false) +} + +func cmdGCDir(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Dir(file) +} + +func repoRoot(t *testing.T) string { + t.Helper() + dir := cmdGCDir(t) + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("go.mod not found walking up from cmd/gc") + } + dir = parent + } +} + +func parseNonTestGoFiles(t *testing.T, fset *token.FileSet, dir string) map[string]*ast.File { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir(%q): %v", dir, err) + } + out := map[string]*ast.File{} + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + f, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + out[name] = f + } + return out +} + +func fileReferencesAnyIdent(f *ast.File, names ...string) bool { + set := make(map[string]bool, len(names)) + for _, n := range names { + set[n] = true + } + found := false + ast.Inspect(f, func(n ast.Node) bool { + if id, ok := n.(*ast.Ident); ok && set[id.Name] { + found = true + return false + } + return true + }) + return found +} diff --git a/engdocs/plans/feature-flags/EXECUTION-PLAN.md b/engdocs/plans/feature-flags/EXECUTION-PLAN.md index d877ba9113..c95cf8120d 100644 --- a/engdocs/plans/feature-flags/EXECUTION-PLAN.md +++ b/engdocs/plans/feature-flags/EXECUTION-PLAN.md @@ -58,6 +58,7 @@ CONCRETE CHECKLIST — every item is a named, mechanically checkable artifact pr - S1-T12 [PR-1d] Graduation + expiry teeth: TestConditionalWritesGraduation reading BD_CONDITIONAL_WRITES_MIN_VERSION via readDotenv, dormant-when-absent and armed-when-present both proven on synthetic dotenv fixtures; expiry check fires ONLY when registry.go is in the PR diff — NO time.Now() in merge-blocking CI (corrects the plan header; wall-clock staleness = doctor WARN only); scripts/rolloutradar DEFERRED with a named owner bead — doctor WARN on FlipDueBy-pending is the interim surface and S4-T4's AC is rewritten against it - S1-T13 [PR-1d] internal/config/undecoded.go RetiredKey mechanism: retiredKeys table, warning-not-fatal downgrade, RemovedIn as version anchor, unknown-key fatality unchanged — red-first with a synthetic retired key. PR-1d merges; S1 exit gate: flag resolves everywhere, nothing consumes it, zero behavior change, all four PRs green alone - S1-T14 [PR-1a, PARALLEL/non-blocking] internal/prompt extraction: move cmd/gc/prompt.go verbatim, export PromptContext/Render/BuildTemplateData, inject the session-name resolver as a param (impl stays cmd/gc-side), convert 3 construction sites + cmd_lint's inline path (~6 files); prompt-boundary import test lands with whichever of PR-1a/PR-1b merges second; recorded fallback if the milestone runs long = AST lint + review checklist per DESIGN open question 5 + - **RESOLVED — shipped as the DESIGN open-question-5 fallback (AST lint), extraction deferred.** A Fable design pass found the extraction ~8 prod files + ~20 exports + ~1600 test lines (the plan's "runs long" trigger met before starting) with identical threat coverage to the lint, and it is non-blocking. Delivered as ONE registry-driven boundary lint, `cmd/gc/prompt_rollout_boundary_test.go`. Two adversarial Fable red-team passes rebuilt the design: the first killed a seam-matching version (`.Env`-only / named-`FuncMap`-only leaked non-Env field writes, unnamed FuncMaps, `td[k]` writes, and FALSE-flagged systemd/subprocess transport); the second killed a broader ident-ban. **Final design** = `TestPromptRenderFilesGateRolloutFlags`: scope to the AST-derived cmd/gc render-file set (anti-vacuity-pinned + per-anchor liveness), and within a render file a flag-value ident (latch field/accessor + each gate's config field + every config fn whose RETURN reads it, fixpoint-derived by parsing internal/config — DESIGN §2.3 lint + the line-929 raw-config addendum) is forbidden EVERYWHERE EXCEPT in a control-flow condition (reading a gate to branch is fine; assigning/returning/laundering-as-a-call-arg its value is not). `TestPromptBoundaryCheckerHasTeeth` pins the rule (if-body, call-arg, value-position cases). Honest residue (value laundered via an intermediate in a NON-render file, a package-internal PromptContext type-alias in an anchor-free file, or a side-effecting condition helper) is review-governed per DESIGN §2.4; the structural fix that closes all of it is the deferred extraction **ga-b1ii8y** (post-S3), whose file manifest IS this lint's derived render-file set. ## S2-conditionalwriter — Land the full ConditionalWriter machinery in gascity's internal/beads with zero consumers: the optional store interface + typed errors + internal Bead.Revision, the BdStore exit-9/exit-13 classifier and lazy four-verb capability probe, a dedicated CAS retry policy (never the blind transient loop), bounded metadata-CAS emulation, native Mem/File implementations with instance capability toggles, CachingStore forward-and-EVICT, the sqlite ConditionalWriter (new-file-only, deploy-lineage staged), the factory mode-stamp + ResolveConditionalWriter(store) thin adapter over the general rollout resolver, the beads.conditional_writes.degraded typed event, and a store-agnostic conformance suite (unit CI over Mem/File/Caching/sqlite; BdStore vs real bd under //go:build integration). Everything mode-blind at store level; no code path anywhere converts ErrConditionalWriteUnsupported into an unconditional write. From 8efdcf73d906e16115f4394b6b7dfddb89774c54 Mon Sep 17 00:00:00 2001 From: Julian Knutsen Date: Fri, 10 Jul 2026 11:46:24 -0700 Subject: [PATCH 047/225] fix(dashboard): restore openapi-ts config, regen typed client, reconcile SPA (#4136) Restores the deleted openapi-ts config + generate:client script, regenerates the dashboard TS client with the P12 error contract, reconciles the SPA to the current API (removes dead agent prime/nudge + bead-close reason), and adds a make dashboard-ci drift-gate. Closes ga-iialk6. --- Makefile | 12 +- .../dist/assets/Activity-DTboxwTI.js | 2 - .../dist/assets/Activity-odvexmkt.js | 2 + .../dist/assets/AgentDetail-CX9z-pqC.js | 1 + .../dist/assets/AgentDetail-DVT9Be-a.js | 1 - ...{Agents-CF9gHKR0.js => Agents-B1unG_7M.js} | 2 +- ...me-usE4zKNv.js => AmbientHome-Tal9JCSE.js} | 2 +- .../dist/assets/BeadDetailModal-3OfdZfdZ.js | 1 + .../dist/assets/BeadDetailModal-BtVrX_Fu.js | 1 - .../dist/assets/Beads-BlpKAjIK.js | 1 + .../dist/assets/Beads-DJjixOgD.js | 1 - .../{Field-Dsl4x4KL.js => Field-9mEFYSnz.js} | 2 +- ...ys0Xia.js => FormulaRunDetail-C-9EnNQt.js} | 8 +- .../dist/assets/Health-C5mLLJQ2.js | 1 - .../dist/assets/Health-Dtx4mLLZ.js | 1 + ...m19JJ4Z.js => LiveSessionPeek-D86UU9cf.js} | 4 +- .../{Mail-767k9Nkh.js => Mail-CA7kddiW.js} | 6 +- ...der-D_D-jYn1.js => PageHeader-4s_Bnmfl.js} | 2 +- .../{Runs-BCTFHOlQ.js => Runs-C0j-IP1W.js} | 2 +- ...r-CeTTAF2S.js => SseIndicator-Cbaw_u69.js} | 2 +- ...er-B3yZa5o4.js => StageLadder-DMyiD1Pv.js} | 2 +- .../{Table-DojZJIvD.js => Table-CU8DfQGc.js} | 2 +- .../dist/assets/agentReads-C0EYRgYm.js | 1 - .../dist/assets/agentReads-D6V_h6J8.js | 1 + ...ants-DBKWGg29.js => constants-BJUwiA6r.js} | 2 +- .../dist/assets/index-C20tCZFz.js | 73 - .../dist/assets/index-CJ6RRl2D.js | 10 + .../dist/assets/index-DH0g_1Xl.css | 1 + .../dist/assets/index-Gx0U3WJJ.css | 1 - ...ctOf-CwPPScnJ.js => projectOf-B27nlS9X.js} | 2 +- .../dist/assets/useListFilters-C0Eq1DLc.js | 1 - .../dist/assets/useListFilters-DQXiYJvO.js | 1 + ...CcAAw.js => useVisibleRefresh-DJ0jEjH6.js} | 2 +- internal/api/dashboardspa/dist/index.html | 4 +- .../frontend/src/attention/registry.test.ts | 1 + .../src/components/LiveSessionPeek.test.tsx | 1 + .../src/components/agent/AgentDirectives.tsx | 63 - .../beads/BeadDependencies.test.tsx | 5 +- .../frontend/src/routes/AgentDetail.test.tsx | 60 +- .../web/frontend/src/routes/AgentDetail.tsx | 50 +- .../frontend/src/routes/Agents.chips.test.ts | 1 + .../frontend/src/routes/Beads.render.test.tsx | 24 +- .../web/frontend/src/routes/Beads.test.tsx | 25 +- .../web/frontend/src/routes/Beads.tsx | 80 +- .../web/frontend/src/supervisor/agentReads.ts | 15 +- .../frontend/src/supervisor/beadReads.test.ts | 2 - .../src/supervisor/beadWrites.test.ts | 42 +- .../web/frontend/src/supervisor/beadWrites.ts | 15 +- .../frontend/src/supervisor/client.test.ts | 123 +- .../web/frontend/src/supervisor/client.ts | 69 +- .../src/supervisor/entityLinks.test.ts | 2 - .../frontend/src/supervisor/mailReads.test.ts | 2 - .../api/dashboardspa/web/openapi-ts.config.ts | 29 + internal/api/dashboardspa/web/package.json | 1 + .../web/shared/src/fixtures/test-city/data.ts | 1 + .../generated/gc-supervisor-client/index.ts | 5 +- .../generated/gc-supervisor-client/sdk.gen.ts | 110 +- .../gc-supervisor-client/types.gen.ts | 4947 +++++++++++++++-- .../generated/gc-supervisor-client/zod.gen.ts | 2763 ++++++++- 59 files changed, 7332 insertions(+), 1261 deletions(-) delete mode 100644 internal/api/dashboardspa/dist/assets/Activity-DTboxwTI.js create mode 100644 internal/api/dashboardspa/dist/assets/Activity-odvexmkt.js create mode 100644 internal/api/dashboardspa/dist/assets/AgentDetail-CX9z-pqC.js delete mode 100644 internal/api/dashboardspa/dist/assets/AgentDetail-DVT9Be-a.js rename internal/api/dashboardspa/dist/assets/{Agents-CF9gHKR0.js => Agents-B1unG_7M.js} (97%) rename internal/api/dashboardspa/dist/assets/{AmbientHome-usE4zKNv.js => AmbientHome-Tal9JCSE.js} (94%) create mode 100644 internal/api/dashboardspa/dist/assets/BeadDetailModal-3OfdZfdZ.js delete mode 100644 internal/api/dashboardspa/dist/assets/BeadDetailModal-BtVrX_Fu.js create mode 100644 internal/api/dashboardspa/dist/assets/Beads-BlpKAjIK.js delete mode 100644 internal/api/dashboardspa/dist/assets/Beads-DJjixOgD.js rename internal/api/dashboardspa/dist/assets/{Field-Dsl4x4KL.js => Field-9mEFYSnz.js} (85%) rename internal/api/dashboardspa/dist/assets/{FormulaRunDetail-CFys0Xia.js => FormulaRunDetail-C-9EnNQt.js} (96%) delete mode 100644 internal/api/dashboardspa/dist/assets/Health-C5mLLJQ2.js create mode 100644 internal/api/dashboardspa/dist/assets/Health-Dtx4mLLZ.js rename internal/api/dashboardspa/dist/assets/{LiveSessionPeek-jm19JJ4Z.js => LiveSessionPeek-D86UU9cf.js} (73%) rename internal/api/dashboardspa/dist/assets/{Mail-767k9Nkh.js => Mail-CA7kddiW.js} (93%) rename internal/api/dashboardspa/dist/assets/{PageHeader-D_D-jYn1.js => PageHeader-4s_Bnmfl.js} (89%) rename internal/api/dashboardspa/dist/assets/{Runs-BCTFHOlQ.js => Runs-C0j-IP1W.js} (90%) rename internal/api/dashboardspa/dist/assets/{SseIndicator-CeTTAF2S.js => SseIndicator-Cbaw_u69.js} (88%) rename internal/api/dashboardspa/dist/assets/{StageLadder-B3yZa5o4.js => StageLadder-DMyiD1Pv.js} (91%) rename internal/api/dashboardspa/dist/assets/{Table-DojZJIvD.js => Table-CU8DfQGc.js} (96%) delete mode 100644 internal/api/dashboardspa/dist/assets/agentReads-C0EYRgYm.js create mode 100644 internal/api/dashboardspa/dist/assets/agentReads-D6V_h6J8.js rename internal/api/dashboardspa/dist/assets/{constants-DBKWGg29.js => constants-BJUwiA6r.js} (95%) delete mode 100644 internal/api/dashboardspa/dist/assets/index-C20tCZFz.js create mode 100644 internal/api/dashboardspa/dist/assets/index-CJ6RRl2D.js create mode 100644 internal/api/dashboardspa/dist/assets/index-DH0g_1Xl.css delete mode 100644 internal/api/dashboardspa/dist/assets/index-Gx0U3WJJ.css rename internal/api/dashboardspa/dist/assets/{projectOf-CwPPScnJ.js => projectOf-B27nlS9X.js} (92%) delete mode 100644 internal/api/dashboardspa/dist/assets/useListFilters-C0Eq1DLc.js create mode 100644 internal/api/dashboardspa/dist/assets/useListFilters-DQXiYJvO.js rename internal/api/dashboardspa/dist/assets/{useVisibleRefresh-D_HCcAAw.js => useVisibleRefresh-DJ0jEjH6.js} (92%) delete mode 100644 internal/api/dashboardspa/web/frontend/src/components/agent/AgentDirectives.tsx create mode 100644 internal/api/dashboardspa/web/openapi-ts.config.ts diff --git a/Makefile b/Makefile index 0f89968523..190e3a8775 100644 --- a/Makefile +++ b/Makefile @@ -734,9 +734,17 @@ dashboard-smoke: dashboard-build cat "$$LOG" >&2; \ exit 1 -## dashboard-ci: rebuild the SPA bundle and fail if the embedded dist/ is stale. -## Used by CI to enforce that internal/api/dashboardspa/dist/ matches the source. +## dashboard-ci: regenerate the typed API client + rebuild the SPA bundle, and +## fail if the generated gc-supervisor-client or the embedded dist/ is stale. +## Used by CI to enforce that the dashboard's generated client (from +## internal/api/openapi.json via openapi-ts.config.ts) and dist/ match sources. dashboard-ci: dashboard-check + cd internal/api/dashboardspa/web && npm run generate:client + @if ! git diff --quiet -- internal/api/dashboardspa/web/shared/src/generated/gc-supervisor-client; then \ + echo "ERROR: dashboard API client is stale — run 'npm run generate:client' in internal/api/dashboardspa/web and commit." >&2; \ + git --no-pager diff --stat -- internal/api/dashboardspa/web/shared/src/generated/gc-supervisor-client; \ + exit 1; \ + fi @if ! git diff --quiet -- internal/api/dashboardspa/dist; then \ echo "ERROR: internal/api/dashboardspa/dist/ is stale — run 'make dashboard-build' and commit." >&2; \ git --no-pager diff --stat -- internal/api/dashboardspa/dist; \ diff --git a/internal/api/dashboardspa/dist/assets/Activity-DTboxwTI.js b/internal/api/dashboardspa/dist/assets/Activity-DTboxwTI.js deleted file mode 100644 index acbb3f9ed0..0000000000 --- a/internal/api/dashboardspa/dist/assets/Activity-DTboxwTI.js +++ /dev/null @@ -1,2 +0,0 @@ -import{J as _,I as q,a as P,K as B,b as F,j as t,B as V,L as W,a9 as $,aa as D,X as A,z as v,S as R,H as M}from"./index-C20tCZFz.js";import{r as C,c as b}from"./routeHighlight-B30gQO2o.js";import{P as z}from"./PageHeader-D_D-jYn1.js";import{b as G,a as H}from"./time-D9v0saHV.js";import{u as O}from"./useVisibleRefresh-D_HCcAAw.js";const U=100,f="24h";async function K(e={}){const s=_("list supervisor events"),a=await q().listEvents(s,{limit:U,since:f,...e}),i=a.items??[];return i.sort((n,l)=>l.seq-n.seq),{...a,items:i,total:Number(a.total)}}const J=[{mode:"all",label:"All"},{mode:"events",label:"Events"},{mode:"deploys",label:"Deploys"},{mode:"commits",label:"Commits"}],L=[{value:"1h",label:"Last hour"},{value:f,label:"Last 24 hours"},{value:"7d",label:"Last 7 days"}],X=[{value:"all",label:"All signals"},{value:"attention",label:"Attention"},{value:"watch",label:"Watch"},{value:"event",label:"Event"}];function Ne(){const e=P(),[s,a]=B(),i=de(s),n=d(i,"events"),l=n?x(s.get("type")):null,o=n?x(s.get("actor")):null,r=n?me(s):f,c=n?pe(s):"all",h=n?x(s.get("q")):null,I=["activity:bundle",M()??"no-city",i,l??"all",o??"all",r,c,h??""].join(":"),{data:u,loading:m,error:S,refresh:k}=F(I,()=>Q(i,l,o,r,c,h));return O(k,3e4),t.jsxs("section",{children:[t.jsx(z,{title:"Activity",synopsis:ie(i,l),meta:t.jsxs(t.Fragment,{children:[S&&t.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:S}),t.jsx(V,{size:"sm",onClick:()=>{k()},disabled:m,children:m?"Refreshing":"Refresh"})]})}),t.jsx(Z,{active:i,eventType:l}),n&&t.jsx(ee,{eventType:l,eventActor:o,eventWindow:r,eventSignal:c,searchParams:s,setSearchParams:a,textFilter:h}),t.jsxs("div",{className:"mt-10 space-y-12",children:[d(i,"events")&&t.jsx(te,{events:u?.events??null,...u?.eventsError!==void 0?{error:u.eventsError}:{},filterActive:l!==null||o!==null||c!=="all"||h!==null,loading:m,attentionSeverity:g=>C(e,"activity",oe(g))}),d(i,"deploys")&&t.jsx(se,{deploys:u?.deploys??null,...u?.deploysError!==void 0?{error:u.deploysError}:{},loading:m,attentionSeverity:g=>C(e,"activity",ce(g))}),d(i,"commits")&&t.jsx(re,{commits:u?.commits??null,...u?.commitsError!==void 0?{error:u.commitsError}:{},loading:m})]})]})}async function Q(e,s,a,i,n,l){const[o,r,c]=await Promise.allSettled([d(e,"events")?Y(s,a,i,n,l):Promise.resolve(null),d(e,"deploys")?A.listBuilds():Promise.resolve(null),d(e,"commits")?A.listCommits("recent-all"):Promise.resolve(null)]);return{commits:j(c),...c.status==="rejected"?{commitsError:v(c.reason,"git commits unavailable")}:{},deploys:j(r),...r.status==="rejected"?{deploysError:v(r.reason,"deploy history unavailable")}:{},events:j(o),...o.status==="rejected"?{eventsError:v(o.reason,"event history unavailable")}:{}}}async function Y(e,s,a,i,n){const l=await K({since:a,...e===null?{}:{type:e},...s===null?{}:{actor:s}}),o=n?.toLowerCase()??"",r=l.items.filter(c=>e!==null&&c.type!==e||s!==null&&c.actor!==s||i!=="all"&&$(c)!==i?!1:o.length===0?!0:xe(c).includes(o));return{...l,items:r,total:r.length}}function j(e){return e.status==="fulfilled"?e.value:null}function Z({active:e,eventType:s}){return t.jsx("nav",{"aria-label":"Activity modes",children:t.jsx("ul",{className:"flex flex-wrap gap-2",children:J.map(({mode:a,label:i})=>{const n=e===a;return t.jsx("li",{children:t.jsx(W,{to:ue(a,s),"aria-current":n?"page":void 0,className:["inline-flex items-center rounded-sm border px-2.5 py-1 text-label uppercase tracking-wider transition-colors duration-150 ease-out-quart focus-mark",n?"border-fg text-fg":"border-rule text-fg-muted hover:text-fg hover:bg-surface-tint"].join(" "),children:i})},a)})})})}function ee({eventActor:e,eventSignal:s,eventType:a,eventWindow:i,searchParams:n,setSearchParams:l,textFilter:o}){return t.jsxs("div",{className:"mt-6 flex flex-wrap items-end gap-4",children:[t.jsxs("label",{className:"grid gap-1 text-label uppercase tracking-wider text-fg-muted",children:["Event window",t.jsx("select",{"aria-label":"Event window",value:i,onChange:r=>p(l,n,"since",r.currentTarget.value,f),className:"min-w-36 rounded-sm border border-rule bg-surface px-2 py-1 text-body normal-case tracking-normal text-fg focus-mark",children:L.map(r=>t.jsx("option",{value:r.value,children:r.label},r.value))})]}),t.jsxs("label",{className:"grid gap-1 text-label uppercase tracking-wider text-fg-muted",children:["Event type",t.jsx("input",{"aria-label":"Event type",value:a??"",onChange:r=>p(l,n,"type",r.currentTarget.value),placeholder:"session.crashed",className:"min-w-44 rounded-sm border border-rule bg-surface px-2 py-1 text-body normal-case tracking-normal text-fg placeholder:text-fg-faint focus-mark"})]}),t.jsxs("label",{className:"grid gap-1 text-label uppercase tracking-wider text-fg-muted",children:["Event actor",t.jsx("input",{"aria-label":"Event actor",value:e??"",onChange:r=>p(l,n,"actor",r.currentTarget.value),placeholder:"supervisor",className:"min-w-40 rounded-sm border border-rule bg-surface px-2 py-1 text-body normal-case tracking-normal text-fg placeholder:text-fg-faint focus-mark"})]}),t.jsxs("label",{className:"grid gap-1 text-label uppercase tracking-wider text-fg-muted",children:["Signal severity",t.jsx("select",{"aria-label":"Signal severity",value:s,onChange:r=>p(l,n,"signal",r.currentTarget.value,"all"),className:"min-w-36 rounded-sm border border-rule bg-surface px-2 py-1 text-body normal-case tracking-normal text-fg focus-mark",children:X.map(r=>t.jsx("option",{value:r.value,children:r.label},r.value))})]}),t.jsxs("label",{className:"grid min-w-56 flex-1 gap-1 text-label uppercase tracking-wider text-fg-muted",children:["Search activity",t.jsx("input",{"aria-label":"Search activity",value:o??"",onChange:r=>p(l,n,"q",r.currentTarget.value),placeholder:"actor, subject, or message",className:"rounded-sm border border-rule bg-surface px-2 py-1 text-body normal-case tracking-normal text-fg placeholder:text-fg-faint focus-mark"})]})]})}function te({error:e,events:s,filterActive:a,loading:i,attentionSeverity:n}){const l=s?.items??[],o=fe(s);return t.jsxs(y,{title:"Supervisor events",meta:s===null?null:`${s.total} events`,children:[e!==void 0&&t.jsxs("p",{className:"text-body text-accent",role:"alert",children:["Event history unavailable: ",e,"."]}),s?.partial===!0&&t.jsxs("p",{className:"text-body text-warn",children:["Event history incomplete",o.length>0?`: ${o.join("; ")}`:"."]}),t.jsxs(N,{label:"Supervisor events",children:[t.jsx("thead",{children:t.jsxs("tr",{className:"border-b border-rule text-label uppercase tracking-wider text-fg-muted",children:[t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Time"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Signal"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Type"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Subject"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Detail"})]})}),t.jsx("tbody",{children:l.length===0?t.jsx(w,{colSpan:5,children:i?"Reading supervisor events.":e!==void 0?"Event history unavailable.":a?"No supervisor events match these filters.":"No supervisor events in this window."}):l.map((r,c)=>t.jsxs("tr",{...b(n(r)),className:`border-b border-rule ${b(n(r)).className??""}`,children:[t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:t.jsx(E,{ts:r.ts})}),t.jsx("td",{className:"py-3 pr-6 align-baseline",children:t.jsx(le,{signal:$(r)})}),t.jsx("td",{className:"py-3 pr-6 align-baseline font-medium text-fg",children:r.type}),t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:r.subject??"·"}),t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:D(r)})]},`${r.seq}:${r.type}:${c}`))})]})]})}function se({deploys:e,error:s,loading:a,attentionSeverity:i}){const n=e?.items??[];return t.jsxs(y,{title:"Deploy history",meta:e?.failed_marker===!0?"failed marker present":e?.source??null,children:[s!==void 0&&t.jsxs("p",{className:"text-body text-accent",role:"alert",children:["Deploy history unavailable: ",s,"."]}),t.jsxs(N,{label:"Deploy history",children:[t.jsx("thead",{children:t.jsxs("tr",{className:"border-b border-rule text-label uppercase tracking-wider text-fg-muted",children:[t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Time"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Status"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Detail"})]})}),t.jsx("tbody",{children:n.length===0?t.jsx(w,{colSpan:3,children:a?"Reading deploy history.":"No deploy records in this window."}):n.map(l=>t.jsxs("tr",{...b(i(l)),className:`border-b border-rule ${b(i(l)).className??""}`,children:[t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:t.jsx(E,{ts:l.at})}),t.jsx("td",{className:"py-3 pr-6 align-baseline",children:t.jsx(ne,{deploy:l})}),t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:l.detail})]},`${l.at}:${l.detail}`))})]})]})}function re({commits:e,error:s,loading:a}){const i=e?.items??[];return t.jsxs(y,{title:"Git commits",meta:e===null?null:e.view,children:[s!==void 0&&t.jsxs("p",{className:"text-body text-accent",role:"alert",children:["Git commits unavailable: ",s,"."]}),t.jsxs(N,{label:"Git commits",children:[t.jsx("thead",{children:t.jsxs("tr",{className:"border-b border-rule text-label uppercase tracking-wider text-fg-muted",children:[t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Time"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Commit"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Author"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Subject"})]})}),t.jsx("tbody",{children:i.length===0?t.jsx(w,{colSpan:4,children:a?"Reading git commits.":"No commits in this window."}):i.map(n=>t.jsx(ae,{commit:n},n.sha))})]})]})}function ae({commit:e}){return t.jsxs("tr",{className:"border-b border-rule",children:[t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:t.jsx(E,{ts:e.date})}),t.jsx("td",{className:"py-3 pr-6 align-baseline font-medium text-fg",children:e.short_sha}),t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:e.author}),t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:e.subject})]})}function y({children:e,meta:s,title:a}){return t.jsxs("section",{"aria-labelledby":T(a),className:"space-y-4",children:[t.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[t.jsx("h2",{id:T(a),className:"text-headline font-semibold tracking-tight text-fg",children:a}),s!==null&&t.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:s})]}),e]})}function N({children:e,label:s}){return t.jsx("div",{className:"overflow-x-auto",children:t.jsx("table",{"aria-label":s,className:"w-full text-body tnum",children:e})})}function w({children:e,colSpan:s}){return t.jsx("tr",{children:t.jsx("td",{colSpan:s,className:"py-10 text-center text-fg-muted italic",children:e})})}function E({ts:e}){return t.jsx("span",{title:H(e),children:G(e)})}function le({signal:e}){const s=e==="attention"?"stuck":e==="watch"?"warn":"neutral";return t.jsx(R,{tone:s,label:e})}function ne({deploy:e}){const s=e.status==="ok"?"ok":e.status==="failed"?"stuck":e.status==="in-progress"?"warn":"neutral";return t.jsx(R,{tone:s,label:e.status})}function ie(e,s){return e==="events"&&s!==null?`Supervisor events filtered to ${s}.`:e==="events"?"Supervisor event history from the active city.":e==="deploys"?"Deploy history from dashboard-local project logs.":e==="commits"?"Recent git commits from the local project checkout.":"Supervisor events, deploy history, and recent project commits."}function oe(e){return`event:${String(e.seq)}:${e.type}`}function ce(e){return e.status==="failed"||e.status==="in-progress"?`deploy:${e.at}:${e.status}`:`deploy:${e.at}`}function ue(e,s){if(e==="all")return"/activity";const a=new URLSearchParams;return a.set("mode",e),e==="events"&&s!==null&&a.set("type",s),`/activity?${a.toString()}`}function d(e,s){return e==="all"||e===s}function de(e){const s=e.get("mode");return s==="events"||s==="deploys"||s==="commits"?s:"all"}function x(e){if(e===null)return null;const s=e.trim();return s.length===0?null:s}function me(e){const s=x(e.get("since"));return s!==null&&L.some(a=>a.value===s)?s:f}function pe(e){const s=x(e.get("signal"));return s==="attention"||s==="watch"||s==="event"?s:"all"}function p(e,s,a,i,n){const l=new URLSearchParams(s),o=i.trim();o.length===0||o===n?l.delete(a):l.set(a,o),e(l)}function xe(e){return[e.type,e.actor,e.subject,e.message,D(e)].filter(s=>typeof s=="string").join(` -`).toLowerCase()}function fe(e){const s=e?.partial_errors;return Array.isArray(s)?s.filter(a=>typeof a=="string"&&a.length>0):[]}function T(e){return`activity-${e.toLowerCase().replace(/[^a-z0-9]+/g,"-")}`}export{Ne as ActivityPage}; diff --git a/internal/api/dashboardspa/dist/assets/Activity-odvexmkt.js b/internal/api/dashboardspa/dist/assets/Activity-odvexmkt.js new file mode 100644 index 0000000000..d2f9b59fcb --- /dev/null +++ b/internal/api/dashboardspa/dist/assets/Activity-odvexmkt.js @@ -0,0 +1,2 @@ +import{I as _,H as q,a as F,J as P,b as W,j as t,B,L as V,a8 as $,a9 as D,W as A,z as v,S as R,F as M}from"./index-CJ6RRl2D.js";import{r as C,c as b}from"./routeHighlight-B30gQO2o.js";import{P as z}from"./PageHeader-4s_Bnmfl.js";import{b as G,a as H}from"./time-D9v0saHV.js";import{u as O}from"./useVisibleRefresh-DJ0jEjH6.js";const U=100,f="24h";async function J(e={}){const s=_("list supervisor events"),a=await q().listEvents(s,{limit:U,since:f,...e}),i=a.items??[];return i.sort((n,l)=>l.seq-n.seq),{...a,items:i,total:Number(a.total)}}const K=[{mode:"all",label:"All"},{mode:"events",label:"Events"},{mode:"deploys",label:"Deploys"},{mode:"commits",label:"Commits"}],L=[{value:"1h",label:"Last hour"},{value:f,label:"Last 24 hours"},{value:"7d",label:"Last 7 days"}],Q=[{value:"all",label:"All signals"},{value:"attention",label:"Attention"},{value:"watch",label:"Watch"},{value:"event",label:"Event"}];function Ne(){const e=F(),[s,a]=P(),i=de(s),n=d(i,"events"),l=n?x(s.get("type")):null,o=n?x(s.get("actor")):null,r=n?me(s):f,c=n?pe(s):"all",h=n?x(s.get("q")):null,I=["activity:bundle",M()??"no-city",i,l??"all",o??"all",r,c,h??""].join(":"),{data:u,loading:m,error:S,refresh:k}=W(I,()=>X(i,l,o,r,c,h));return O(k,3e4),t.jsxs("section",{children:[t.jsx(z,{title:"Activity",synopsis:ie(i,l),meta:t.jsxs(t.Fragment,{children:[S&&t.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:S}),t.jsx(B,{size:"sm",onClick:()=>{k()},disabled:m,children:m?"Refreshing":"Refresh"})]})}),t.jsx(Z,{active:i,eventType:l}),n&&t.jsx(ee,{eventType:l,eventActor:o,eventWindow:r,eventSignal:c,searchParams:s,setSearchParams:a,textFilter:h}),t.jsxs("div",{className:"mt-10 space-y-12",children:[d(i,"events")&&t.jsx(te,{events:u?.events??null,...u?.eventsError!==void 0?{error:u.eventsError}:{},filterActive:l!==null||o!==null||c!=="all"||h!==null,loading:m,attentionSeverity:g=>C(e,"activity",oe(g))}),d(i,"deploys")&&t.jsx(se,{deploys:u?.deploys??null,...u?.deploysError!==void 0?{error:u.deploysError}:{},loading:m,attentionSeverity:g=>C(e,"activity",ce(g))}),d(i,"commits")&&t.jsx(re,{commits:u?.commits??null,...u?.commitsError!==void 0?{error:u.commitsError}:{},loading:m})]})]})}async function X(e,s,a,i,n,l){const[o,r,c]=await Promise.allSettled([d(e,"events")?Y(s,a,i,n,l):Promise.resolve(null),d(e,"deploys")?A.listBuilds():Promise.resolve(null),d(e,"commits")?A.listCommits("recent-all"):Promise.resolve(null)]);return{commits:j(c),...c.status==="rejected"?{commitsError:v(c.reason,"git commits unavailable")}:{},deploys:j(r),...r.status==="rejected"?{deploysError:v(r.reason,"deploy history unavailable")}:{},events:j(o),...o.status==="rejected"?{eventsError:v(o.reason,"event history unavailable")}:{}}}async function Y(e,s,a,i,n){const l=await J({since:a,...e===null?{}:{type:e},...s===null?{}:{actor:s}}),o=n?.toLowerCase()??"",r=l.items.filter(c=>e!==null&&c.type!==e||s!==null&&c.actor!==s||i!=="all"&&$(c)!==i?!1:o.length===0?!0:xe(c).includes(o));return{...l,items:r,total:r.length}}function j(e){return e.status==="fulfilled"?e.value:null}function Z({active:e,eventType:s}){return t.jsx("nav",{"aria-label":"Activity modes",children:t.jsx("ul",{className:"flex flex-wrap gap-2",children:K.map(({mode:a,label:i})=>{const n=e===a;return t.jsx("li",{children:t.jsx(V,{to:ue(a,s),"aria-current":n?"page":void 0,className:["inline-flex items-center rounded-sm border px-2.5 py-1 text-label uppercase tracking-wider transition-colors duration-150 ease-out-quart focus-mark",n?"border-fg text-fg":"border-rule text-fg-muted hover:text-fg hover:bg-surface-tint"].join(" "),children:i})},a)})})})}function ee({eventActor:e,eventSignal:s,eventType:a,eventWindow:i,searchParams:n,setSearchParams:l,textFilter:o}){return t.jsxs("div",{className:"mt-6 flex flex-wrap items-end gap-4",children:[t.jsxs("label",{className:"grid gap-1 text-label uppercase tracking-wider text-fg-muted",children:["Event window",t.jsx("select",{"aria-label":"Event window",value:i,onChange:r=>p(l,n,"since",r.currentTarget.value,f),className:"min-w-36 rounded-sm border border-rule bg-surface px-2 py-1 text-body normal-case tracking-normal text-fg focus-mark",children:L.map(r=>t.jsx("option",{value:r.value,children:r.label},r.value))})]}),t.jsxs("label",{className:"grid gap-1 text-label uppercase tracking-wider text-fg-muted",children:["Event type",t.jsx("input",{"aria-label":"Event type",value:a??"",onChange:r=>p(l,n,"type",r.currentTarget.value),placeholder:"session.crashed",className:"min-w-44 rounded-sm border border-rule bg-surface px-2 py-1 text-body normal-case tracking-normal text-fg placeholder:text-fg-faint focus-mark"})]}),t.jsxs("label",{className:"grid gap-1 text-label uppercase tracking-wider text-fg-muted",children:["Event actor",t.jsx("input",{"aria-label":"Event actor",value:e??"",onChange:r=>p(l,n,"actor",r.currentTarget.value),placeholder:"supervisor",className:"min-w-40 rounded-sm border border-rule bg-surface px-2 py-1 text-body normal-case tracking-normal text-fg placeholder:text-fg-faint focus-mark"})]}),t.jsxs("label",{className:"grid gap-1 text-label uppercase tracking-wider text-fg-muted",children:["Signal severity",t.jsx("select",{"aria-label":"Signal severity",value:s,onChange:r=>p(l,n,"signal",r.currentTarget.value,"all"),className:"min-w-36 rounded-sm border border-rule bg-surface px-2 py-1 text-body normal-case tracking-normal text-fg focus-mark",children:Q.map(r=>t.jsx("option",{value:r.value,children:r.label},r.value))})]}),t.jsxs("label",{className:"grid min-w-56 flex-1 gap-1 text-label uppercase tracking-wider text-fg-muted",children:["Search activity",t.jsx("input",{"aria-label":"Search activity",value:o??"",onChange:r=>p(l,n,"q",r.currentTarget.value),placeholder:"actor, subject, or message",className:"rounded-sm border border-rule bg-surface px-2 py-1 text-body normal-case tracking-normal text-fg placeholder:text-fg-faint focus-mark"})]})]})}function te({error:e,events:s,filterActive:a,loading:i,attentionSeverity:n}){const l=s?.items??[],o=fe(s);return t.jsxs(y,{title:"Supervisor events",meta:s===null?null:`${s.total} events`,children:[e!==void 0&&t.jsxs("p",{className:"text-body text-accent",role:"alert",children:["Event history unavailable: ",e,"."]}),s?.partial===!0&&t.jsxs("p",{className:"text-body text-warn",children:["Event history incomplete",o.length>0?`: ${o.join("; ")}`:"."]}),t.jsxs(N,{label:"Supervisor events",children:[t.jsx("thead",{children:t.jsxs("tr",{className:"border-b border-rule text-label uppercase tracking-wider text-fg-muted",children:[t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Time"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Signal"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Type"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Subject"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Detail"})]})}),t.jsx("tbody",{children:l.length===0?t.jsx(w,{colSpan:5,children:i?"Reading supervisor events.":e!==void 0?"Event history unavailable.":a?"No supervisor events match these filters.":"No supervisor events in this window."}):l.map((r,c)=>t.jsxs("tr",{...b(n(r)),className:`border-b border-rule ${b(n(r)).className??""}`,children:[t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:t.jsx(E,{ts:r.ts})}),t.jsx("td",{className:"py-3 pr-6 align-baseline",children:t.jsx(le,{signal:$(r)})}),t.jsx("td",{className:"py-3 pr-6 align-baseline font-medium text-fg",children:r.type}),t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:r.subject??"·"}),t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:D(r)})]},`${r.seq}:${r.type}:${c}`))})]})]})}function se({deploys:e,error:s,loading:a,attentionSeverity:i}){const n=e?.items??[];return t.jsxs(y,{title:"Deploy history",meta:e?.failed_marker===!0?"failed marker present":e?.source??null,children:[s!==void 0&&t.jsxs("p",{className:"text-body text-accent",role:"alert",children:["Deploy history unavailable: ",s,"."]}),t.jsxs(N,{label:"Deploy history",children:[t.jsx("thead",{children:t.jsxs("tr",{className:"border-b border-rule text-label uppercase tracking-wider text-fg-muted",children:[t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Time"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Status"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Detail"})]})}),t.jsx("tbody",{children:n.length===0?t.jsx(w,{colSpan:3,children:a?"Reading deploy history.":"No deploy records in this window."}):n.map(l=>t.jsxs("tr",{...b(i(l)),className:`border-b border-rule ${b(i(l)).className??""}`,children:[t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:t.jsx(E,{ts:l.at})}),t.jsx("td",{className:"py-3 pr-6 align-baseline",children:t.jsx(ne,{deploy:l})}),t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:l.detail})]},`${l.at}:${l.detail}`))})]})]})}function re({commits:e,error:s,loading:a}){const i=e?.items??[];return t.jsxs(y,{title:"Git commits",meta:e===null?null:e.view,children:[s!==void 0&&t.jsxs("p",{className:"text-body text-accent",role:"alert",children:["Git commits unavailable: ",s,"."]}),t.jsxs(N,{label:"Git commits",children:[t.jsx("thead",{children:t.jsxs("tr",{className:"border-b border-rule text-label uppercase tracking-wider text-fg-muted",children:[t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Time"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Commit"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Author"}),t.jsx("th",{scope:"col",className:"pb-3 pr-6 text-left font-medium",children:"Subject"})]})}),t.jsx("tbody",{children:i.length===0?t.jsx(w,{colSpan:4,children:a?"Reading git commits.":"No commits in this window."}):i.map(n=>t.jsx(ae,{commit:n},n.sha))})]})]})}function ae({commit:e}){return t.jsxs("tr",{className:"border-b border-rule",children:[t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:t.jsx(E,{ts:e.date})}),t.jsx("td",{className:"py-3 pr-6 align-baseline font-medium text-fg",children:e.short_sha}),t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:e.author}),t.jsx("td",{className:"py-3 pr-6 align-baseline text-fg-muted",children:e.subject})]})}function y({children:e,meta:s,title:a}){return t.jsxs("section",{"aria-labelledby":T(a),className:"space-y-4",children:[t.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[t.jsx("h2",{id:T(a),className:"text-headline font-semibold tracking-tight text-fg",children:a}),s!==null&&t.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:s})]}),e]})}function N({children:e,label:s}){return t.jsx("div",{className:"overflow-x-auto",children:t.jsx("table",{"aria-label":s,className:"w-full text-body tnum",children:e})})}function w({children:e,colSpan:s}){return t.jsx("tr",{children:t.jsx("td",{colSpan:s,className:"py-10 text-center text-fg-muted italic",children:e})})}function E({ts:e}){return t.jsx("span",{title:H(e),children:G(e)})}function le({signal:e}){const s=e==="attention"?"stuck":e==="watch"?"warn":"neutral";return t.jsx(R,{tone:s,label:e})}function ne({deploy:e}){const s=e.status==="ok"?"ok":e.status==="failed"?"stuck":e.status==="in-progress"?"warn":"neutral";return t.jsx(R,{tone:s,label:e.status})}function ie(e,s){return e==="events"&&s!==null?`Supervisor events filtered to ${s}.`:e==="events"?"Supervisor event history from the active city.":e==="deploys"?"Deploy history from dashboard-local project logs.":e==="commits"?"Recent git commits from the local project checkout.":"Supervisor events, deploy history, and recent project commits."}function oe(e){return`event:${String(e.seq)}:${e.type}`}function ce(e){return e.status==="failed"||e.status==="in-progress"?`deploy:${e.at}:${e.status}`:`deploy:${e.at}`}function ue(e,s){if(e==="all")return"/activity";const a=new URLSearchParams;return a.set("mode",e),e==="events"&&s!==null&&a.set("type",s),`/activity?${a.toString()}`}function d(e,s){return e==="all"||e===s}function de(e){const s=e.get("mode");return s==="events"||s==="deploys"||s==="commits"?s:"all"}function x(e){if(e===null)return null;const s=e.trim();return s.length===0?null:s}function me(e){const s=x(e.get("since"));return s!==null&&L.some(a=>a.value===s)?s:f}function pe(e){const s=x(e.get("signal"));return s==="attention"||s==="watch"||s==="event"?s:"all"}function p(e,s,a,i,n){const l=new URLSearchParams(s),o=i.trim();o.length===0||o===n?l.delete(a):l.set(a,o),e(l)}function xe(e){return[e.type,e.actor,e.subject,e.message,D(e)].filter(s=>typeof s=="string").join(` +`).toLowerCase()}function fe(e){const s=e?.partial_errors;return Array.isArray(s)?s.filter(a=>typeof a=="string"&&a.length>0):[]}function T(e){return`activity-${e.toLowerCase().replace(/[^a-z0-9]+/g,"-")}`}export{Ne as ActivityPage}; diff --git a/internal/api/dashboardspa/dist/assets/AgentDetail-CX9z-pqC.js b/internal/api/dashboardspa/dist/assets/AgentDetail-CX9z-pqC.js new file mode 100644 index 0000000000..d26f137220 --- /dev/null +++ b/internal/api/dashboardspa/dist/assets/AgentDetail-CX9z-pqC.js @@ -0,0 +1 @@ +import{j as e,r,p as I,q as Z,t as ee,v as se,w as te,u as ae,x as D,l as ne,y as re,z as V,f as le,A as ie,B as G,L as H,s as oe,S as ce,G as q}from"./index-CJ6RRl2D.js";import{u as de,R as ue,B as me}from"./BeadDetailModal-3OfdZfdZ.js";import{P as M}from"./PageHeader-4s_Bnmfl.js";import{f as R}from"./time-D9v0saHV.js";import{P as xe}from"./constants-BJUwiA6r.js";import{L as fe,a as ge}from"./LiveSessionPeek-D86UU9cf.js";import{e as he}from"./context-window-Cu9zl36t.js";import"./format-fte2CeYD.js";import"./Field-9mEFYSnz.js";function pe({beads:n,error:o,loading:l,onSelect:i}){return e.jsxs("section",{className:"mb-12",children:[e.jsxs("header",{className:"flex items-baseline justify-between mb-4",children:[e.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Beads assigned"}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:l?"·":n.length})]}),o!==null?e.jsx("p",{className:"text-body text-accent",role:"alert",children:o}):l?e.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading beads."}):n.length===0?e.jsx("p",{className:"text-body text-fg-muted italic",children:"No beads assigned to this agent."}):e.jsx("ul",{className:"space-y-2",children:n.map(a=>e.jsxs("li",{className:"flex items-baseline gap-3 min-w-0",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum shrink-0",children:a.id}),e.jsx("button",{type:"button",onClick:()=>i(a),className:"text-body text-fg hover:text-accent truncate min-w-0 text-left focus-mark",title:`Open ${a.id}`,children:a.title}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint shrink-0",children:a.status})]},a.id))})]})}function je({messages:n,loading:o,error:l,now:i}){return e.jsxs("section",{className:"mt-12",children:[e.jsxs("header",{className:"flex items-baseline justify-between mb-4",children:[e.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Chat thread"}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:o?"·":n.length})]}),e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint mb-4",children:e.jsxs("span",{className:"text-accent",children:["▲ ",xe]})}),o?e.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading messages."}):l!==null?e.jsx("p",{className:"text-body text-accent",role:"alert",children:l}):n.length===0?e.jsx("p",{className:"text-body text-fg-muted italic",children:"No messages between operator and this agent."}):e.jsx("ul",{className:"space-y-6",children:n.map(a=>e.jsxs("li",{className:"space-y-2 pb-4 border-b border-rule last:border-0",children:[e.jsxs("header",{className:"flex items-baseline justify-between gap-3",children:[e.jsxs("div",{className:"text-label uppercase tracking-wider text-fg-muted truncate",children:[e.jsx("span",{className:"text-fg font-medium",children:a.from}),e.jsx("span",{className:"mx-1.5 text-fg-faint",children:"→"}),e.jsx("span",{children:a.to})]}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum shrink-0",children:R(a.created_at,i)})]}),a.subject&&e.jsx("p",{className:"text-body font-medium text-fg",children:a.subject}),e.jsx("pre",{className:"text-body whitespace-pre-wrap leading-relaxed text-fg overflow-x-auto",children:a.body})]},a.id))})]})}function be({session:n}){return e.jsxs("section",{children:[e.jsx("header",{className:"flex items-baseline justify-between mb-4",children:e.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Live peek"})}),e.jsx(fe,{sessionId:n.id,stream:ge(n),showBadge:!0,showCaption:!0})]})}function we({session:n,now:o}){const l=he(n),i=[{label:"Rig",value:n.rig??"·"},{label:"Pool",value:n.pool??"·"},{label:"Provider",value:n.provider??"·"},{label:"Model",value:n.model??"·"},{label:"Context",value:typeof l=="number"?e.jsxs("span",{className:`tnum ${l>=95?"text-accent":l>=80?"text-warn":"text-fg"}`,children:[l,"%"]}):"·"},{label:"Attached",value:n.attached?"yes":"no"},{label:"Created",value:e.jsx("span",{className:"tnum",children:R(n.created_at,o)})},{label:"Last active",value:e.jsx("span",{className:"tnum",children:R(n.last_active,o)})}];return e.jsx("dl",{className:"grid grid-cols-2 sm:grid-cols-4 gap-x-8 gap-y-5 mb-12",children:i.map(a=>e.jsxs("div",{children:[e.jsx("dt",{className:"text-label uppercase tracking-wider text-fg-faint mb-1",children:a.label}),e.jsx("dd",{className:"text-body text-fg",children:a.value})]},a.label))})}const Ne=2e3,ve=6e4;function ye({enabled:n,intervalMs:o,load:l,formatError:i,initialBackoffMs:a=Ne,maxBackoffMs:S=ve}){const[h,m]=r.useState({status:"idle"}),p=r.useRef(0),x=r.useRef(0);return r.useEffect(()=>{if(!n){p.current=0,x.current=0,m({status:"idle"});return}let j=!1,f=new AbortController;const N=()=>{p.current=0,x.current=0},v=()=>{const d=Math.min(a*2**p.current,S);p.current+=1,x.current=Date.now()+d},y=async()=>{if(Date.now()c.status==="ready"?{...c,refreshing:!0,error:""}:{status:"loading"});try{const c=await l(d.signal);if(j||d.signal.aborted)return;N(),m({status:"ready",data:c,refreshing:!1,error:""})}catch(c){if(j||d.signal.aborted)return;v();const b=i?i(c):I(c);m(s=>s.status==="ready"?{...s,refreshing:!1,error:b}:{status:"failed",error:b})}};y();const A=window.setInterval(()=>{document.hidden||y()},o);return()=>{j=!0,f.abort(),window.clearInterval(A)}},[n,o,l,i,a,S]),h}const Ae=1e4,z=200;function Re(){const{slug:n=""}=Z(),o=ee(),{viewingAs:l}=se(),i=te(),[a,S]=r.useState(null),[h,m]=r.useState(null),[p,x]=r.useState(null),[j,f]=r.useState(null),[N,v]=r.useState(null),[y,A]=r.useState(null),d=ae(),c=r.useMemo(()=>{try{return decodeURIComponent(n)}catch(t){return D({component:"AgentDetail",operation:"decodeSlug",message:I(t)}),n}},[n]),b=r.useCallback(async()=>{try{const{items:t}=await ne();S(t??[]),f(null)}catch(t){f(t instanceof Error?t.message:"sessions failed")}},[]),s=r.useMemo(()=>a===null?null:a.find(t=>t.session_name===c)??a.find(t=>t.alias===c)??a.find(t=>t.id===c)??null,[a,c]),E=r.useMemo(()=>s===null?[]:[s.alias??"",s.session_name,s.id],[s]),B=r.useCallback(async()=>{if(E.length===0){m([]),x(null);return}try{const{items:t}=await re(E,{includeClosed:!0});m(t),x(null)}catch(t){m([]),x(V(t,"assigned beads unavailable")),D({component:"AgentDetail",operation:"refreshBeads",message:I(t)})}},[E]);r.useEffect(()=>{b()},[b]),r.useEffect(()=>{B()},[B]),le([q.session,q.bead],()=>{b(),B()});const U=r.useMemo(()=>{if(s===null||h===null)return[];const t=new Set;return s.alias&&t.add(s.alias),s.session_name&&t.add(s.session_name),t.add(s.id),h.filter(w=>{if(w.assignee!==void 0&&t.has(w.assignee))return!0;const g=w.metadata;return!!(g&&(g.session_id===s.id||g.session_name&&g.session_name===s.session_name))})},[s,h]),P=r.useMemo(()=>{if(s===null)return[];const t=new Set;return s.alias&&t.add(s.alias.toLowerCase()),s.session_name&&t.add(s.session_name.toLowerCase()),t.add(s.id.toLowerCase()),[...t]},[s]),T=r.useMemo(()=>[l.alias.toLowerCase(),i.operatorWireAlias.toLowerCase()],[l.alias,i.operatorWireAlias]),X=r.useCallback(async()=>{const{items:t}=await ie("all",l.alias,i);return t},[l.alias,i]),u=ye({enabled:s!==null,intervalMs:Ae,load:X,formatError:V}),$=u.status==="loading",K=u.status==="failed"||u.status==="ready"&&u.error.length>0?u.error:null,L=de(s?.id??null),W=r.useMemo(()=>{const t=u.status==="ready"?u.data:[],w=new Set(P),g=new Set(T),C=t.filter(_=>{const k=(_.from??"").toLowerCase(),O=(_.to??"").toLowerCase();return!!(g.has(k)&&w.has(O)||w.has(k)&&g.has(O))});return C.sort((_,k)=>_.created_at.localeCompare(k.created_at)),C.length>z?C.slice(C.length-z):C},[u,P,T]);if(a===null)return e.jsx("section",{children:e.jsx(M,{title:"Agent",synopsis:"Loading session list."})});if(s===null)return e.jsxs("section",{children:[e.jsx(M,{title:"Agent",synopsis:e.jsxs(e.Fragment,{children:["No session matches ",e.jsx("code",{className:"text-fg",children:c}),"."]}),meta:e.jsx(G,{size:"sm",tone:"quiet",onClick:()=>o("/agents"),children:"← Agents"})}),e.jsxs("p",{className:"text-body text-fg-muted max-w-prose",children:["The slug doesn't match any current session's session_name, alias, or id. Sessions are listed at"," ",e.jsx(H,{to:"/agents",className:"text-accent hover:underline",children:"/agents"}),"."]})]});const J=s.alias??s.title??s.id,Q=oe(s.state),F=t=>{v(null),A(t)},Y=()=>{v(null),A(null)};return e.jsxs("section",{children:[e.jsx(M,{title:J,synopsis:e.jsxs("span",{className:"flex flex-wrap items-baseline gap-x-3 gap-y-1",children:[e.jsx(ce,{tone:Q,label:s.state,...s.attached?{trailing:"att"}:{},...s.reason?{title:`reason: ${s.reason}`}:{}}),e.jsx("span",{className:"text-fg-faint",children:"·"}),e.jsx("code",{className:"text-fg-muted",children:s.template??"—"}),s.session_name&&s.session_name!==s.alias&&e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"text-fg-faint",children:"·"}),e.jsx("span",{className:"text-fg-faint",children:s.session_name})]}),e.jsx("span",{className:"text-fg-faint",children:"·"}),e.jsxs("span",{className:"text-fg-faint",children:["id ",e.jsx("code",{className:"text-fg-muted",children:s.id})]})]}),meta:e.jsx(H,{to:"/agents",children:e.jsx(G,{size:"sm",tone:"quiet",children:"← Agents"})})}),j&&e.jsx("p",{className:"text-body text-accent mb-6",role:"alert",children:j}),e.jsx(we,{session:s,now:d}),e.jsx(pe,{beads:U,error:p,loading:h===null,onSelect:t=>{A(null),v(t)}}),e.jsx(ue,{view:L.view,loading:L.loading,error:L.error,now:d,onOpenBead:F}),e.jsx(be,{session:s}),e.jsx(je,{messages:W,loading:$,error:K,now:d}),e.jsx(me,{open:N!==null||y!==null,onClose:Y,beadId:N?.id??y,initialBead:N,onOpenBead:F})]})}export{Re as AgentDetailPage}; diff --git a/internal/api/dashboardspa/dist/assets/AgentDetail-DVT9Be-a.js b/internal/api/dashboardspa/dist/assets/AgentDetail-DVT9Be-a.js deleted file mode 100644 index 389ba00146..0000000000 --- a/internal/api/dashboardspa/dist/assets/AgentDetail-DVT9Be-a.js +++ /dev/null @@ -1 +0,0 @@ -import{j as e,B as T,r,p as F,q as ie,t as ce,v as oe,w as de,u as ue,x as U,l as me,y as xe,z as X,f as fe,A as ge,C as he,L as K,s as pe,S as je,G as W}from"./index-C20tCZFz.js";import{u as be,R as Ne,B as we}from"./BeadDetailModal-BtVrX_Fu.js";import{P as D}from"./PageHeader-D_D-jYn1.js";import{f as O}from"./time-D9v0saHV.js";import{P as ve}from"./constants-DBKWGg29.js";import{L as ye,a as Ae}from"./LiveSessionPeek-jm19JJ4Z.js";import{e as Ce}from"./context-window-Cu9zl36t.js";import{f as Se}from"./agentReads-C0EYRgYm.js";import"./format-fte2CeYD.js";import"./Field-Dsl4x4KL.js";function ke({beads:n,error:c,loading:l,onSelect:i}){return e.jsxs("section",{className:"mb-12",children:[e.jsxs("header",{className:"flex items-baseline justify-between mb-4",children:[e.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Beads assigned"}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:l?"·":n.length})]}),c!==null?e.jsx("p",{className:"text-body text-accent",role:"alert",children:c}):l?e.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading beads."}):n.length===0?e.jsx("p",{className:"text-body text-fg-muted italic",children:"No beads assigned to this agent."}):e.jsx("ul",{className:"space-y-2",children:n.map(a=>e.jsxs("li",{className:"flex items-baseline gap-3 min-w-0",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum shrink-0",children:a.id}),e.jsx("button",{type:"button",onClick:()=>i(a),className:"text-body text-fg hover:text-accent truncate min-w-0 text-left focus-mark",title:`Open ${a.id}`,children:a.title}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint shrink-0",children:a.status})]},a.id))})]})}function _e({messages:n,loading:c,error:l,now:i}){return e.jsxs("section",{className:"mt-12",children:[e.jsxs("header",{className:"flex items-baseline justify-between mb-4",children:[e.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Chat thread"}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:c?"·":n.length})]}),e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint mb-4",children:e.jsxs("span",{className:"text-accent",children:["▲ ",ve]})}),c?e.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading messages."}):l!==null?e.jsx("p",{className:"text-body text-accent",role:"alert",children:l}):n.length===0?e.jsx("p",{className:"text-body text-fg-muted italic",children:"No messages between operator and this agent."}):e.jsx("ul",{className:"space-y-6",children:n.map(a=>e.jsxs("li",{className:"space-y-2 pb-4 border-b border-rule last:border-0",children:[e.jsxs("header",{className:"flex items-baseline justify-between gap-3",children:[e.jsxs("div",{className:"text-label uppercase tracking-wider text-fg-muted truncate",children:[e.jsx("span",{className:"text-fg font-medium",children:a.from}),e.jsx("span",{className:"mx-1.5 text-fg-faint",children:"→"}),e.jsx("span",{children:a.to})]}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum shrink-0",children:O(a.created_at,i)})]}),a.subject&&e.jsx("p",{className:"text-body font-medium text-fg",children:a.subject}),e.jsx("pre",{className:"text-body whitespace-pre-wrap leading-relaxed text-fg overflow-x-auto",children:a.body})]},a.id))})]})}function Ee({alias:n,prompt:c,loading:l,error:i,onRefresh:a}){const j=i?.status===404||i?.kind==="not_found",f=c!==null?`${c.length.toLocaleString()} chars`:l?"loading":i!==null?"—":"·";return e.jsxs("section",{className:"mt-12",children:[e.jsxs("header",{className:"flex items-baseline justify-between mb-4",children:[e.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Directives"}),e.jsxs("div",{className:"flex items-baseline gap-3",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:f}),e.jsx(T,{size:"sm",tone:"quiet",onClick:a,disabled:l,children:l?"Refreshing":"Refresh"})]})]}),l&&c===null&&i===null?e.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading directives."}):j?e.jsxs("p",{className:"text-body text-warn",children:["Agent ",e.jsx("code",{className:"text-fg",children:n})," has no entry in city config."]}):i!==null?e.jsxs("p",{className:"text-body text-accent",role:"alert",children:[i.status?`${i.status} `:"",i.message]}):c!==null?e.jsx("pre",{className:"text-body whitespace-pre-wrap leading-relaxed text-fg overflow-x-auto max-h-[60vh] overflow-y-auto",children:c}):null]})}function Le({session:n}){return e.jsxs("section",{children:[e.jsx("header",{className:"flex items-baseline justify-between mb-4",children:e.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Live peek"})}),e.jsx(ye,{sessionId:n.id,stream:Ae(n),showBadge:!0,showCaption:!0})]})}function Be({session:n,now:c}){const l=Ce(n),i=[{label:"Rig",value:n.rig??"·"},{label:"Pool",value:n.pool??"·"},{label:"Provider",value:n.provider??"·"},{label:"Model",value:n.model??"·"},{label:"Context",value:typeof l=="number"?e.jsxs("span",{className:`tnum ${l>=95?"text-accent":l>=80?"text-warn":"text-fg"}`,children:[l,"%"]}):"·"},{label:"Attached",value:n.attached?"yes":"no"},{label:"Created",value:e.jsx("span",{className:"tnum",children:O(n.created_at,c)})},{label:"Last active",value:e.jsx("span",{className:"tnum",children:O(n.last_active,c)})}];return e.jsx("dl",{className:"grid grid-cols-2 sm:grid-cols-4 gap-x-8 gap-y-5 mb-12",children:i.map(a=>e.jsxs("div",{children:[e.jsx("dt",{className:"text-label uppercase tracking-wider text-fg-faint mb-1",children:a.label}),e.jsx("dd",{className:"text-body text-fg",children:a.value})]},a.label))})}const Me=2e3,Re=6e4;function Pe({enabled:n,intervalMs:c,load:l,formatError:i,initialBackoffMs:a=Me,maxBackoffMs:j=Re}){const[f,g]=r.useState({status:"idle"}),b=r.useRef(0),h=r.useRef(0);return r.useEffect(()=>{if(!n){b.current=0,h.current=0,g({status:"idle"});return}let N=!1,p=new AbortController;const w=()=>{b.current=0,h.current=0},v=()=>{const u=Math.min(a*2**b.current,j);b.current+=1,h.current=Date.now()+u},y=async()=>{if(Date.now()m.status==="ready"?{...m,refreshing:!0,error:""}:{status:"loading"});try{const m=await l(u.signal);if(N||u.signal.aborted)return;w(),g({status:"ready",data:m,refreshing:!1,error:""})}catch(m){if(N||u.signal.aborted)return;v();const C=i?i(m):F(m);g(E=>E.status==="ready"?{...E,refreshing:!1,error:C}:{status:"failed",error:C})}};y();const A=window.setInterval(()=>{document.hidden||y()},c);return()=>{N=!0,p.abort(),window.clearInterval(A)}},[n,c,l,i,a,j]),f}const Ie=1e4,J=200;function Ue(){const{slug:n=""}=ie(),c=ce(),{viewingAs:l}=oe(),i=de(),[a,j]=r.useState(null),[f,g]=r.useState(null),[b,h]=r.useState(null),[N,p]=r.useState(null),[w,v]=r.useState(null),[y,A]=r.useState(null),u=ue(),[m,C]=r.useState(null),[E,V]=r.useState(!1),[Q,$]=r.useState(null),S=r.useMemo(()=>{try{return decodeURIComponent(n)}catch(t){return U({component:"AgentDetail",operation:"decodeSlug",message:F(t)}),n}},[n]),M=r.useCallback(async()=>{try{const{items:t}=await me();j(t??[]),p(null)}catch(t){p(t instanceof Error?t.message:"sessions failed")}},[]),s=r.useMemo(()=>a===null?null:a.find(t=>t.session_name===S)??a.find(t=>t.alias===S)??a.find(t=>t.id===S)??null,[a,S]),R=r.useMemo(()=>s===null?[]:[s.alias??"",s.session_name,s.id],[s]),P=r.useCallback(async()=>{if(R.length===0){g([]),h(null);return}try{const{items:t}=await xe(R,{includeClosed:!0});g(t),h(null)}catch(t){g([]),h(X(t,"assigned beads unavailable")),U({component:"AgentDetail",operation:"refreshBeads",message:F(t)})}},[R]);r.useEffect(()=>{M()},[M]),r.useEffect(()=>{P()},[P]),fe([W.session,W.bead],()=>{M(),P()});const Y=r.useMemo(()=>{if(s===null||f===null)return[];const t=new Set;return s.alias&&t.add(s.alias),s.session_name&&t.add(s.session_name),t.add(s.id),f.filter(o=>{if(o.assignee!==void 0&&t.has(o.assignee))return!0;const d=o.metadata;return!!(d&&(d.session_id===s.id||d.session_name&&d.session_name===s.session_name))})},[s,f]),q=r.useMemo(()=>{if(s===null)return[];const t=new Set;return s.alias&&t.add(s.alias.toLowerCase()),s.session_name&&t.add(s.session_name.toLowerCase()),t.add(s.id.toLowerCase()),[...t]},[s]),z=r.useMemo(()=>[l.alias.toLowerCase(),i.operatorWireAlias.toLowerCase()],[l.alias,i.operatorWireAlias]),Z=r.useCallback(async()=>{const{items:t}=await ge("all",l.alias,i);return t},[l.alias,i]),x=Pe({enabled:s!==null,intervalMs:Ie,load:Z,formatError:X}),ee=x.status==="loading",se=x.status==="failed"||x.status==="ready"&&x.error.length>0?x.error:null,k=r.useMemo(()=>s===null?null:s.alias??s.template??null,[s]),te=r.useCallback(async()=>{if(k!==null){V(!0),$(null);try{const t=await Se(k);C(t.prompt)}catch(t){const o=he(t,"directives fetch failed"),d={message:o.message};o.status!==void 0&&(d.status=o.status),o.kind!==void 0&&(d.kind=o.kind),$(d),C(null)}finally{V(!1)}}},[k]),I=be(s?.id??null),ae=r.useMemo(()=>{const t=x.status==="ready"?x.data:[],o=new Set(q),d=new Set(z),_=t.filter(L=>{const B=(L.from??"").toLowerCase(),H=(L.to??"").toLowerCase();return!!(d.has(B)&&o.has(H)||o.has(B)&&d.has(H))});return _.sort((L,B)=>L.created_at.localeCompare(B.created_at)),_.length>J?_.slice(_.length-J):_},[x,q,z]);if(a===null)return e.jsx("section",{children:e.jsx(D,{title:"Agent",synopsis:"Loading session list."})});if(s===null)return e.jsxs("section",{children:[e.jsx(D,{title:"Agent",synopsis:e.jsxs(e.Fragment,{children:["No session matches ",e.jsx("code",{className:"text-fg",children:S}),"."]}),meta:e.jsx(T,{size:"sm",tone:"quiet",onClick:()=>c("/agents"),children:"← Agents"})}),e.jsxs("p",{className:"text-body text-fg-muted max-w-prose",children:["The slug doesn't match any current session's session_name, alias, or id. Sessions are listed at"," ",e.jsx(K,{to:"/agents",className:"text-accent hover:underline",children:"/agents"}),"."]})]});const ne=s.alias??s.title??s.id,re=pe(s.state),G=t=>{v(null),A(t)},le=()=>{v(null),A(null)};return e.jsxs("section",{children:[e.jsx(D,{title:ne,synopsis:e.jsxs("span",{className:"flex flex-wrap items-baseline gap-x-3 gap-y-1",children:[e.jsx(je,{tone:re,label:s.state,...s.attached?{trailing:"att"}:{},...s.reason?{title:`reason: ${s.reason}`}:{}}),e.jsx("span",{className:"text-fg-faint",children:"·"}),e.jsx("code",{className:"text-fg-muted",children:s.template??"—"}),s.session_name&&s.session_name!==s.alias&&e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"text-fg-faint",children:"·"}),e.jsx("span",{className:"text-fg-faint",children:s.session_name})]}),e.jsx("span",{className:"text-fg-faint",children:"·"}),e.jsxs("span",{className:"text-fg-faint",children:["id ",e.jsx("code",{className:"text-fg-muted",children:s.id})]})]}),meta:e.jsx(K,{to:"/agents",children:e.jsx(T,{size:"sm",tone:"quiet",children:"← Agents"})})}),N&&e.jsx("p",{className:"text-body text-accent mb-6",role:"alert",children:N}),e.jsx(Be,{session:s,now:u}),e.jsx(ke,{beads:Y,error:b,loading:f===null,onSelect:t=>{A(null),v(t)}}),e.jsx(Ne,{view:I.view,loading:I.loading,error:I.error,now:u,onOpenBead:G}),e.jsx(Le,{session:s}),k!==null&&e.jsx(Ee,{alias:k,prompt:m,loading:E,error:Q,onRefresh:()=>{te()}}),e.jsx(_e,{messages:ae,loading:ee,error:se,now:u}),e.jsx(we,{open:w!==null||y!==null,onClose:le,beadId:w?.id??y,initialBead:w,onOpenBead:G})]})}export{Ue as AgentDetailPage}; diff --git a/internal/api/dashboardspa/dist/assets/Agents-CF9gHKR0.js b/internal/api/dashboardspa/dist/assets/Agents-B1unG_7M.js similarity index 97% rename from internal/api/dashboardspa/dist/assets/Agents-CF9gHKR0.js rename to internal/api/dashboardspa/dist/assets/Agents-B1unG_7M.js index fb2653cfb2..d70ef017a0 100644 --- a/internal/api/dashboardspa/dist/assets/Agents-CF9gHKR0.js +++ b/internal/api/dashboardspa/dist/assets/Agents-B1unG_7M.js @@ -1,2 +1,2 @@ -import{r as d,s as T,j as e,L as I,u as te,S as $,B as y,a as fe,b as R,l as xe,c as he,d as be,e as ve,f as je,g as Ne,h as ye,R as ke,i as H,k as we,G as K,m as Ce,n as Se,o as Ae}from"./index-C20tCZFz.js";import{e as X}from"./context-window-Cu9zl36t.js";import{r as J,a as Re}from"./routeHighlight-B30gQO2o.js";import{i as $e,c as O,a as Ie,s as _e,b as se,d as E,e as Me,L as Le}from"./projectOf-CwPPScnJ.js";import{M as ne}from"./constants-DBKWGg29.js";import{P as Pe}from"./PageHeader-D_D-jYn1.js";import{S as Oe,P as Ee}from"./SseIndicator-CeTTAF2S.js";import{f as ae}from"./time-D9v0saHV.js";import{L as ie,i as Q}from"./LiveSessionPeek-jm19JJ4Z.js";import{T as Te}from"./Table-DojZJIvD.js";import{l as Be}from"./agentReads-C0EYRgYm.js";import"./format-fte2CeYD.js";function qe(s){const n=s.indexOf("-");if(n<=0)return!1;const o=s.slice(0,n),l=s.slice(n+1);return!l||!/^[a-z0-9]+$/.test(l)||!(o==="gc"||o==="td"||o==="th"||/^[a-z]{4}$/.test(o))?!1:/[0-9]/.test(l)}function ze(s){const n=s.trim();if(qe(n))return{role:n,sessionId:n};for(let o=n.length-1;o>=0;o--){const l=n.charAt(o);if(l!=="-"&&l!=="_"&&l!=="/")continue;const c=n.slice(o+1);if(c&&/^(?:gc|td|th|[a-z]{4})-[a-z0-9]{1,32}$/.test(c))return{role:n.slice(0,o),sessionId:c}}return{role:n}}const We="in_progress";function De(s){return Ie(_e(s).label)}function Fe(s){const n=O(s.template??"");return n.length>0?n:O(s.session_name??s.id)}function Z(s){const n=s.session.last_active?Date.parse(s.session.last_active):NaN;return Number.isFinite(n)?n:0}function Ue(s,n){const o=new Map;for(const r of n){if(r.status!==We)continue;const i=r.assignee?.trim();if(!i)continue;const{sessionId:m}=ze(i);m&&!o.has(m)&&o.set(m,r)}const l=[];for(const r of s){if(!$e(r))continue;const i=o.get(r.id);l.push({session:r,rig:De(r),worker:Fe(r),...i?{bead:i}:{}})}l.sort((r,i)=>Z(i)-Z(r));const c=new Map;for(const r of l)c.set(r.rig,(c.get(r.rig)??0)+1);const u=Array.from(c,([r,i])=>({rig:r,count:i})).sort((r,i)=>i.count-r.count||r.rig.localeCompare(i.rig));return{workers:l,byRig:u,total:l.length}}function Ve(s){if(s.total===0)return"No workers active right now.";const n=s.total===1?"worker":"workers",o=s.byRig.map(l=>`${l.rig} (${l.count})`).join(", ");return`${s.total} ${n} active across ${o}.`}function Ye({worker:s,accent:n,onPeek:o}){const l=te(),{session:c,rig:u,bead:r}=s,i=n?T(c.state):"neutral";return e.jsx("li",{className:"px-2 py-2 -mx-2 rounded-sm transition-colors duration-150 ease-out-quart hover:bg-surface-tint/60",children:e.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[e.jsxs("div",{className:"min-w-0 text-body text-fg",children:[e.jsxs("button",{type:"button",onClick:()=>o(c.id),className:"group text-left cursor-pointer focus-mark",title:`Open ${u} · ${s.worker} transcript`,children:[e.jsx("span",{className:"font-medium group-hover:text-accent",children:u}),e.jsxs("span",{className:"text-fg-faint","aria-hidden":"true",children:[" ","·"," "]}),e.jsx("span",{className:"text-fg-muted group-hover:text-accent",children:s.worker})]}),r&&e.jsxs(I,{to:`/beads?bead=${encodeURIComponent(r.id)}`,className:"hover:text-accent focus-mark",title:`Open ${r.id}`,children:[e.jsxs("span",{className:"text-fg-faint","aria-hidden":"true",children:[" ","→"," "]}),e.jsx("span",{className:"tnum text-fg-muted",children:r.id}),e.jsxs("span",{className:"text-fg-muted",children:[": ",r.title]})]})]}),e.jsxs("div",{className:"flex items-baseline gap-3 shrink-0",children:[e.jsx($,{tone:i,label:c.state}),e.jsx("span",{className:"tnum text-fg-muted w-10 text-right",children:ae(c.last_active,l)}),e.jsx(y,{size:"sm",tone:"quiet",onClick:()=>o(c.id),children:"Peek"})]})]})})}function Ge(s){return s.running===!0||s.state==="active"||s.state==="running"}function He({beads:s,sessions:n,sessionsLoading:o,sessionsError:l}){const c=d.useMemo(()=>Ue(n,s),[n,s]),u=d.useMemo(()=>Ve(c),[c]),[r,i]=d.useState(null),m=d.useMemo(()=>r?c.workers.find(f=>f.session.id===r)??null:null,[c.workers,r]),k=d.useMemo(()=>c.workers.findIndex(f=>T(f.session.state)==="stuck"),[c.workers]),p=n.length===0,w=l!==null&&p,N=o&&p,_=w||N?"—":c.total;return e.jsxs("section",{className:"mb-10","aria-label":"Workers active",children:[e.jsxs("header",{className:"flex items-baseline justify-between border-b border-rule pb-2 mb-4",children:[e.jsx("h2",{className:"text-headline text-fg",children:"Workers active"}),e.jsx("span",{className:"text-label tnum text-fg-muted",children:_})]}),w?e.jsx("p",{className:"text-body text-fg-muted",role:"status",children:"Worker status unavailable."}):N?e.jsx("p",{className:"text-body text-fg-muted",role:"status",children:"Checking worker status…"}):c.total===0?e.jsx("p",{className:"text-body text-fg-muted",children:"No workers active right now."}):e.jsxs(e.Fragment,{children:[e.jsx("p",{className:"text-body text-fg-muted mb-4",children:u}),e.jsx("ul",{className:"space-y-1",children:c.workers.map((f,C)=>e.jsx(Ye,{worker:f,accent:C===k,onPeek:i},f.session.id))})]}),e.jsx(ne,{open:m!==null,onClose:()=>i(null),title:m?`${m.rig} · ${m.worker}`:"Transcript",caption:m?.bead?e.jsxs(I,{to:`/beads?bead=${encodeURIComponent(m.bead.id)}`,className:"text-fg-muted hover:text-accent focus-mark",title:`Open ${m.bead.id}`,children:[e.jsx("span",{className:"tnum",children:m.bead.id}),e.jsxs("span",{children:[": ",m.bead.title]})]}):"Live transcript from the supervisor's session stream.",widthClass:"max-w-5xl",children:e.jsx(ie,{sessionId:r,stream:m?Ge(m.session):!1,showBadge:!0,showCaption:!0})})]})}function ee(s){return s.session?.name??s.name}function Ke(s){return!s.suspended&&(s.state==="active"||s.state==="running"||s.running===!0)}function Xe(s,n){return Ke(s)||n==="attention"}function P(s){const n=O(s.name);return se(s)?n:`${E(s).label} · ${n}`}const Je=s=>[s.name,s.display_name,s.pool,s.rig,s.provider,s.model].filter(n=>typeof n=="string"&&n.length>0);function ft(){const s=fe(),{data:n,loading:o,error:l,refresh:c}=R("agents",Be),u=R("sessions",xe),r=R("beads:in-flight",()=>he()),i=d.useMemo(()=>n?.items??[],[n]),m=d.useMemo(()=>(u.data?.items??[]).map(t=>t.id).sort(),[u.data]),k=d.useMemo(()=>i.map(t=>t.name).sort(),[i]),p=R(`agent-pending:${k.join(",")}:${m.join(",")}`,()=>be(i,u.data?.items??[])),w=d.useMemo(()=>{const t=new Map;for(const a of u.data?.items??[])a.session_name&&t.set(a.session_name,a.id);return t},[u.data]),N=d.useMemo(()=>{const t=new Map;for(const a of p.data??[])t.set(a.agentName,a);return t},[p.data]),_=d.useMemo(()=>{const t=(p.data??[]).map(g=>({agentName:g.agentName,...g.pending.prompt===void 0?{}:{prompt:g.pending.prompt}})),a=new Map(i.map(g=>[g.name,g]));return ve(i,t).flatMap(g=>{const b=a.get(g.name);return b===void 0?[]:[{need:g,label:P(b),slug:ee(b)}]})},[i,p.data]),f=te(),[C,oe]=d.useState(!0),[M,re]=d.useState(""),[v,B]=d.useState(""),[S,q]=d.useState(null),[z,W]=d.useState(null),[D,F]=d.useState(null),[j,U]=d.useState(null),x=d.useMemo(()=>S===null?null:i.find(t=>t.name===S)??null,[i,S]),V=d.useMemo(()=>{const t=x?.session?.name;return t?w.get(t)??null:null},[x,w]),le=je([K.session,K.bead,"agent."],()=>{c(),r.refresh(),u.refresh()}),ce=d.useMemo(()=>st(i),[i]),h=Ne(),L=d.useCallback(async(t,a)=>{if(!h){U({sessionId:t.sessionId,action:a}),W(null),F(null);try{await ye(t.sessionId,{action:a,request_id:t.pending.request_id}),W(`responded to ${t.agentName}`),await p.refresh()}catch(g){F(g instanceof Error?g.message:"response failed")}finally{U(null)}}},[p,h]),A=d.useMemo(()=>Array.from(new Set(i.filter(t=>!se(t)).map(t=>E(t).label))).sort((t,a)=>t.localeCompare(a)),[i]);d.useEffect(()=>{v!==""&&!A.includes(v)&&B("")},[A,v]);const Y=d.useMemo(()=>{const t=M.trim().toLowerCase();return i.filter(a=>{if(v!==""&&E(a).label!==v)return!1;const g=J(s,"agents",a.name);return C&&!Xe(a,g)?!1:t.length===0?!0:Je(a).some(b=>b.toLowerCase().includes(t))})},[i,v,C,M,s]),de=d.useMemo(()=>t=>Re(J(s,"agents",t.name)),[s]),G=l!==null&&i.length===0,ue=G?"Agent roster unavailable.":i.length===0?"No agents configured.":"No agents match the current search or filter.",me=d.useMemo(()=>[{key:"name",label:"Agent",sortable:!0,sortValue:t=>P(t),render:t=>{const a=Me(t),g=t.display_name&&t.display_name!==t.name?t.display_name:t.provider??t.model??"",b=!t.session,ge=b?`${t.name} — configured but not running; detail will show no live session`:`Open drilldown for ${t.name}`,pe=b?"text-fg-muted":"text-fg";return e.jsxs("div",{className:"min-w-0",children:[e.jsx(I,{to:`/agents/${encodeURIComponent(ee(t))}`,className:`block ${pe} truncate hover:text-accent focus-mark ${a?"font-normal italic":"font-medium"}`,title:ge,children:P(t)}),g&&e.jsx("div",{className:"text-label uppercase tracking-wider text-fg-faint mt-1 truncate",children:g})]})}},{key:"state",label:"State",sortable:!0,sortValue:t=>t.state,render:t=>e.jsx($,{tone:T(t.state),label:t.state,...t.session?.attached?{trailing:"att"}:{},...t.unavailable_reason?{title:`unavailable: ${t.unavailable_reason}`}:{}}),className:"w-32"},{key:"activity",label:"Activity",sortable:!0,sortValue:t=>t.activity??"",render:t=>{const a=N.get(t.name);return a!==void 0?e.jsxs("div",{className:"min-w-0",children:[e.jsx($,{tone:"stuck",label:"needs you"}),e.jsx("p",{className:"mt-1 truncate text-fg-muted",title:a.pending.prompt,children:a.pending.prompt??a.pending.kind})]}):e.jsx("span",{className:"text-fg-muted",children:t.activity??(t.running?"running":"·")})},className:"w-28"},{key:"context",label:"Context",sortable:!0,sortValue:t=>X(t)??-1,align:"right",render:t=>{const a=X(t);if(typeof a!="number")return e.jsx("span",{className:"text-fg-faint",children:"·"});const g=typeof t.context_pct=="number"&&t.context_pct!==a?`gc reports ${t.context_pct}% against ${t.context_window??"?"}-token window; scaled to model's true window`:void 0;return e.jsxs("span",{title:g,className:`tnum ${a>=95?"text-accent font-medium":a>=80?"text-warn font-medium":"text-fg-muted"}`,children:[a,"%"]})},className:"w-24"},{key:"last_active",label:"Last active",sortable:!0,sortValue:t=>t.session?.last_activity??"",render:t=>{const a=t.session?.last_activity;return a?e.jsx("span",{className:"tnum text-fg-muted",children:ae(a,f)}):e.jsx("span",{className:"text-fg-faint tnum",children:"·"})},className:"w-32"},{key:"actions",label:"",render:t=>{if(!t.session)return null;const a=N.get(t.name);return e.jsxs("div",{className:"flex justify-end gap-2",children:[a!==void 0&&e.jsxs(e.Fragment,{children:[h&&e.jsx(ke,{}),e.jsx(y,{size:"sm",tone:"quiet",title:h?H:void 0,disabled:h||j?.sessionId===a.sessionId,onClick:()=>{L(a,"approve")},children:j?.sessionId===a.sessionId&&j.action==="approve"?"Approving":"Approve"}),e.jsx(y,{size:"sm",tone:"quiet",title:h?H:void 0,disabled:h||j?.sessionId===a.sessionId,onClick:()=>{L(a,"deny")},children:j?.sessionId===a.sessionId&&j.action==="deny"?"Denying":"Deny"}),e.jsx(Ze,{command:we(t.name)})]}),e.jsx(y,{size:"sm",tone:"quiet",onClick:()=>q(t.name),children:"Peek"})]})},align:"right",className:"w-80"}],[L,f,N,h,j]);return e.jsxs("section",{children:[e.jsx(Pe,{title:"Agents",synopsis:G?"Agent roster unavailable.":ce,meta:e.jsxs(e.Fragment,{children:[e.jsx(Oe,{state:le}),l&&e.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:l}),e.jsx(Ee,{show:n?.partial===!0,label:"roster partial",title:n?.partial_errors?.join(` +import{r as d,s as T,j as e,L as I,u as te,S as $,B as y,a as fe,b as R,l as xe,c as he,d as be,e as ve,f as je,g as Ne,h as ye,R as ke,i as H,k as we,G as K,m as Ce,n as Se,o as Ae}from"./index-CJ6RRl2D.js";import{e as X}from"./context-window-Cu9zl36t.js";import{r as J,a as Re}from"./routeHighlight-B30gQO2o.js";import{i as $e,c as O,a as Ie,s as _e,b as se,d as E,e as Me,L as Le}from"./projectOf-B27nlS9X.js";import{M as ne}from"./constants-BJUwiA6r.js";import{P as Pe}from"./PageHeader-4s_Bnmfl.js";import{S as Oe,P as Ee}from"./SseIndicator-Cbaw_u69.js";import{f as ae}from"./time-D9v0saHV.js";import{L as ie,i as Q}from"./LiveSessionPeek-D86UU9cf.js";import{T as Te}from"./Table-CU8DfQGc.js";import{l as Be}from"./agentReads-D6V_h6J8.js";import"./format-fte2CeYD.js";function qe(s){const n=s.indexOf("-");if(n<=0)return!1;const o=s.slice(0,n),l=s.slice(n+1);return!l||!/^[a-z0-9]+$/.test(l)||!(o==="gc"||o==="td"||o==="th"||/^[a-z]{4}$/.test(o))?!1:/[0-9]/.test(l)}function ze(s){const n=s.trim();if(qe(n))return{role:n,sessionId:n};for(let o=n.length-1;o>=0;o--){const l=n.charAt(o);if(l!=="-"&&l!=="_"&&l!=="/")continue;const c=n.slice(o+1);if(c&&/^(?:gc|td|th|[a-z]{4})-[a-z0-9]{1,32}$/.test(c))return{role:n.slice(0,o),sessionId:c}}return{role:n}}const We="in_progress";function De(s){return Ie(_e(s).label)}function Fe(s){const n=O(s.template??"");return n.length>0?n:O(s.session_name??s.id)}function Z(s){const n=s.session.last_active?Date.parse(s.session.last_active):NaN;return Number.isFinite(n)?n:0}function Ue(s,n){const o=new Map;for(const r of n){if(r.status!==We)continue;const i=r.assignee?.trim();if(!i)continue;const{sessionId:m}=ze(i);m&&!o.has(m)&&o.set(m,r)}const l=[];for(const r of s){if(!$e(r))continue;const i=o.get(r.id);l.push({session:r,rig:De(r),worker:Fe(r),...i?{bead:i}:{}})}l.sort((r,i)=>Z(i)-Z(r));const c=new Map;for(const r of l)c.set(r.rig,(c.get(r.rig)??0)+1);const u=Array.from(c,([r,i])=>({rig:r,count:i})).sort((r,i)=>i.count-r.count||r.rig.localeCompare(i.rig));return{workers:l,byRig:u,total:l.length}}function Ve(s){if(s.total===0)return"No workers active right now.";const n=s.total===1?"worker":"workers",o=s.byRig.map(l=>`${l.rig} (${l.count})`).join(", ");return`${s.total} ${n} active across ${o}.`}function Ye({worker:s,accent:n,onPeek:o}){const l=te(),{session:c,rig:u,bead:r}=s,i=n?T(c.state):"neutral";return e.jsx("li",{className:"px-2 py-2 -mx-2 rounded-sm transition-colors duration-150 ease-out-quart hover:bg-surface-tint/60",children:e.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[e.jsxs("div",{className:"min-w-0 text-body text-fg",children:[e.jsxs("button",{type:"button",onClick:()=>o(c.id),className:"group text-left cursor-pointer focus-mark",title:`Open ${u} · ${s.worker} transcript`,children:[e.jsx("span",{className:"font-medium group-hover:text-accent",children:u}),e.jsxs("span",{className:"text-fg-faint","aria-hidden":"true",children:[" ","·"," "]}),e.jsx("span",{className:"text-fg-muted group-hover:text-accent",children:s.worker})]}),r&&e.jsxs(I,{to:`/beads?bead=${encodeURIComponent(r.id)}`,className:"hover:text-accent focus-mark",title:`Open ${r.id}`,children:[e.jsxs("span",{className:"text-fg-faint","aria-hidden":"true",children:[" ","→"," "]}),e.jsx("span",{className:"tnum text-fg-muted",children:r.id}),e.jsxs("span",{className:"text-fg-muted",children:[": ",r.title]})]})]}),e.jsxs("div",{className:"flex items-baseline gap-3 shrink-0",children:[e.jsx($,{tone:i,label:c.state}),e.jsx("span",{className:"tnum text-fg-muted w-10 text-right",children:ae(c.last_active,l)}),e.jsx(y,{size:"sm",tone:"quiet",onClick:()=>o(c.id),children:"Peek"})]})]})})}function Ge(s){return s.running===!0||s.state==="active"||s.state==="running"}function He({beads:s,sessions:n,sessionsLoading:o,sessionsError:l}){const c=d.useMemo(()=>Ue(n,s),[n,s]),u=d.useMemo(()=>Ve(c),[c]),[r,i]=d.useState(null),m=d.useMemo(()=>r?c.workers.find(f=>f.session.id===r)??null:null,[c.workers,r]),k=d.useMemo(()=>c.workers.findIndex(f=>T(f.session.state)==="stuck"),[c.workers]),p=n.length===0,w=l!==null&&p,N=o&&p,_=w||N?"—":c.total;return e.jsxs("section",{className:"mb-10","aria-label":"Workers active",children:[e.jsxs("header",{className:"flex items-baseline justify-between border-b border-rule pb-2 mb-4",children:[e.jsx("h2",{className:"text-headline text-fg",children:"Workers active"}),e.jsx("span",{className:"text-label tnum text-fg-muted",children:_})]}),w?e.jsx("p",{className:"text-body text-fg-muted",role:"status",children:"Worker status unavailable."}):N?e.jsx("p",{className:"text-body text-fg-muted",role:"status",children:"Checking worker status…"}):c.total===0?e.jsx("p",{className:"text-body text-fg-muted",children:"No workers active right now."}):e.jsxs(e.Fragment,{children:[e.jsx("p",{className:"text-body text-fg-muted mb-4",children:u}),e.jsx("ul",{className:"space-y-1",children:c.workers.map((f,C)=>e.jsx(Ye,{worker:f,accent:C===k,onPeek:i},f.session.id))})]}),e.jsx(ne,{open:m!==null,onClose:()=>i(null),title:m?`${m.rig} · ${m.worker}`:"Transcript",caption:m?.bead?e.jsxs(I,{to:`/beads?bead=${encodeURIComponent(m.bead.id)}`,className:"text-fg-muted hover:text-accent focus-mark",title:`Open ${m.bead.id}`,children:[e.jsx("span",{className:"tnum",children:m.bead.id}),e.jsxs("span",{children:[": ",m.bead.title]})]}):"Live transcript from the supervisor's session stream.",widthClass:"max-w-5xl",children:e.jsx(ie,{sessionId:r,stream:m?Ge(m.session):!1,showBadge:!0,showCaption:!0})})]})}function ee(s){return s.session?.name??s.name}function Ke(s){return!s.suspended&&(s.state==="active"||s.state==="running"||s.running===!0)}function Xe(s,n){return Ke(s)||n==="attention"}function P(s){const n=O(s.name);return se(s)?n:`${E(s).label} · ${n}`}const Je=s=>[s.name,s.display_name,s.pool,s.rig,s.provider,s.model].filter(n=>typeof n=="string"&&n.length>0);function ft(){const s=fe(),{data:n,loading:o,error:l,refresh:c}=R("agents",Be),u=R("sessions",xe),r=R("beads:in-flight",()=>he()),i=d.useMemo(()=>n?.items??[],[n]),m=d.useMemo(()=>(u.data?.items??[]).map(t=>t.id).sort(),[u.data]),k=d.useMemo(()=>i.map(t=>t.name).sort(),[i]),p=R(`agent-pending:${k.join(",")}:${m.join(",")}`,()=>be(i,u.data?.items??[])),w=d.useMemo(()=>{const t=new Map;for(const a of u.data?.items??[])a.session_name&&t.set(a.session_name,a.id);return t},[u.data]),N=d.useMemo(()=>{const t=new Map;for(const a of p.data??[])t.set(a.agentName,a);return t},[p.data]),_=d.useMemo(()=>{const t=(p.data??[]).map(g=>({agentName:g.agentName,...g.pending.prompt===void 0?{}:{prompt:g.pending.prompt}})),a=new Map(i.map(g=>[g.name,g]));return ve(i,t).flatMap(g=>{const b=a.get(g.name);return b===void 0?[]:[{need:g,label:P(b),slug:ee(b)}]})},[i,p.data]),f=te(),[C,oe]=d.useState(!0),[M,re]=d.useState(""),[v,B]=d.useState(""),[S,q]=d.useState(null),[z,W]=d.useState(null),[D,F]=d.useState(null),[j,U]=d.useState(null),x=d.useMemo(()=>S===null?null:i.find(t=>t.name===S)??null,[i,S]),V=d.useMemo(()=>{const t=x?.session?.name;return t?w.get(t)??null:null},[x,w]),le=je([K.session,K.bead,"agent."],()=>{c(),r.refresh(),u.refresh()}),ce=d.useMemo(()=>st(i),[i]),h=Ne(),L=d.useCallback(async(t,a)=>{if(!h){U({sessionId:t.sessionId,action:a}),W(null),F(null);try{await ye(t.sessionId,{action:a,request_id:t.pending.request_id}),W(`responded to ${t.agentName}`),await p.refresh()}catch(g){F(g instanceof Error?g.message:"response failed")}finally{U(null)}}},[p,h]),A=d.useMemo(()=>Array.from(new Set(i.filter(t=>!se(t)).map(t=>E(t).label))).sort((t,a)=>t.localeCompare(a)),[i]);d.useEffect(()=>{v!==""&&!A.includes(v)&&B("")},[A,v]);const Y=d.useMemo(()=>{const t=M.trim().toLowerCase();return i.filter(a=>{if(v!==""&&E(a).label!==v)return!1;const g=J(s,"agents",a.name);return C&&!Xe(a,g)?!1:t.length===0?!0:Je(a).some(b=>b.toLowerCase().includes(t))})},[i,v,C,M,s]),de=d.useMemo(()=>t=>Re(J(s,"agents",t.name)),[s]),G=l!==null&&i.length===0,ue=G?"Agent roster unavailable.":i.length===0?"No agents configured.":"No agents match the current search or filter.",me=d.useMemo(()=>[{key:"name",label:"Agent",sortable:!0,sortValue:t=>P(t),render:t=>{const a=Me(t),g=t.display_name&&t.display_name!==t.name?t.display_name:t.provider??t.model??"",b=!t.session,ge=b?`${t.name} — configured but not running; detail will show no live session`:`Open drilldown for ${t.name}`,pe=b?"text-fg-muted":"text-fg";return e.jsxs("div",{className:"min-w-0",children:[e.jsx(I,{to:`/agents/${encodeURIComponent(ee(t))}`,className:`block ${pe} truncate hover:text-accent focus-mark ${a?"font-normal italic":"font-medium"}`,title:ge,children:P(t)}),g&&e.jsx("div",{className:"text-label uppercase tracking-wider text-fg-faint mt-1 truncate",children:g})]})}},{key:"state",label:"State",sortable:!0,sortValue:t=>t.state,render:t=>e.jsx($,{tone:T(t.state),label:t.state,...t.session?.attached?{trailing:"att"}:{},...t.unavailable_reason?{title:`unavailable: ${t.unavailable_reason}`}:{}}),className:"w-32"},{key:"activity",label:"Activity",sortable:!0,sortValue:t=>t.activity??"",render:t=>{const a=N.get(t.name);return a!==void 0?e.jsxs("div",{className:"min-w-0",children:[e.jsx($,{tone:"stuck",label:"needs you"}),e.jsx("p",{className:"mt-1 truncate text-fg-muted",title:a.pending.prompt,children:a.pending.prompt??a.pending.kind})]}):e.jsx("span",{className:"text-fg-muted",children:t.activity??(t.running?"running":"·")})},className:"w-28"},{key:"context",label:"Context",sortable:!0,sortValue:t=>X(t)??-1,align:"right",render:t=>{const a=X(t);if(typeof a!="number")return e.jsx("span",{className:"text-fg-faint",children:"·"});const g=typeof t.context_pct=="number"&&t.context_pct!==a?`gc reports ${t.context_pct}% against ${t.context_window??"?"}-token window; scaled to model's true window`:void 0;return e.jsxs("span",{title:g,className:`tnum ${a>=95?"text-accent font-medium":a>=80?"text-warn font-medium":"text-fg-muted"}`,children:[a,"%"]})},className:"w-24"},{key:"last_active",label:"Last active",sortable:!0,sortValue:t=>t.session?.last_activity??"",render:t=>{const a=t.session?.last_activity;return a?e.jsx("span",{className:"tnum text-fg-muted",children:ae(a,f)}):e.jsx("span",{className:"text-fg-faint tnum",children:"·"})},className:"w-32"},{key:"actions",label:"",render:t=>{if(!t.session)return null;const a=N.get(t.name);return e.jsxs("div",{className:"flex justify-end gap-2",children:[a!==void 0&&e.jsxs(e.Fragment,{children:[h&&e.jsx(ke,{}),e.jsx(y,{size:"sm",tone:"quiet",title:h?H:void 0,disabled:h||j?.sessionId===a.sessionId,onClick:()=>{L(a,"approve")},children:j?.sessionId===a.sessionId&&j.action==="approve"?"Approving":"Approve"}),e.jsx(y,{size:"sm",tone:"quiet",title:h?H:void 0,disabled:h||j?.sessionId===a.sessionId,onClick:()=>{L(a,"deny")},children:j?.sessionId===a.sessionId&&j.action==="deny"?"Denying":"Deny"}),e.jsx(Ze,{command:we(t.name)})]}),e.jsx(y,{size:"sm",tone:"quiet",onClick:()=>q(t.name),children:"Peek"})]})},align:"right",className:"w-80"}],[L,f,N,h,j]);return e.jsxs("section",{children:[e.jsx(Pe,{title:"Agents",synopsis:G?"Agent roster unavailable.":ce,meta:e.jsxs(e.Fragment,{children:[e.jsx(Oe,{state:le}),l&&e.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:l}),e.jsx(Ee,{show:n?.partial===!0,label:"roster partial",title:n?.partial_errors?.join(` `)??"one or more agent backends unavailable"}),e.jsx(y,{size:"sm",onClick:()=>{c()},disabled:o,children:o?"Refreshing":"Refresh"})]})}),e.jsx(Qe,{rows:_}),e.jsx(He,{beads:r.data?.items??[],sessions:u.data?.items??[],sessionsLoading:u.loading,sessionsError:u.error}),e.jsxs("header",{className:"flex items-baseline justify-between border-b border-rule pb-2 mb-4",children:[e.jsx("h2",{className:"text-headline text-fg",children:"Available agents"}),e.jsx("span",{className:"text-label tnum text-fg-muted",children:i.length})]}),e.jsxs("div",{className:"mb-6 space-y-3",children:[e.jsx(Le,{value:M,onChange:re,placeholder:"Search agents by alias, rig, pool, provider",matchCount:Y.length,totalCount:i.length,ariaLabel:"Search agents"}),e.jsxs("div",{className:"flex items-baseline gap-6",children:[e.jsxs("label",{className:"inline-flex items-baseline gap-2 text-label uppercase tracking-wider text-fg-muted cursor-pointer hover:text-fg transition-colors duration-150 ease-out-quart",children:[e.jsx("input",{type:"checkbox",checked:C,onChange:t=>oe(t.target.checked),style:{accentColor:"oklch(var(--fg-muted))"},className:"translate-y-[2px]"}),e.jsx("span",{children:"running"})]}),A.length>1&&e.jsxs("label",{className:"inline-flex items-baseline gap-2 text-label uppercase tracking-wider text-fg-muted",children:[e.jsx("span",{children:"rig"}),e.jsxs("select",{value:v,onChange:t=>B(t.target.value),"aria-label":"Rig filter",className:"text-label uppercase tracking-wider text-fg-muted bg-transparent border-0 focus-mark cursor-pointer hover:text-fg transition-colors duration-150 ease-out-quart",children:[e.jsx("option",{value:"",children:"all rigs"}),A.map(t=>e.jsx("option",{value:t,children:t},t))]})]})]})]}),z&&e.jsx("div",{className:"mb-4 text-body text-fg-muted",role:"status",children:z}),D&&e.jsx("div",{className:"mb-4 text-body text-accent",role:"alert",children:D}),e.jsx(Te,{rows:Y,columns:me,rowKey:t=>t.name,rowProps:de,empty:ue,initialSort:{key:"last_active",dir:"desc"}}),e.jsx(ne,{open:S!==null,onClose:()=>q(null),title:x?.name??S??"Transcript",caption:x&&x.session&&!V?u.loading?"Resolving session…":`No live session matches "${x.session.name}".`:Q(x)?"Live transcript from the supervisor's session stream.":"Snapshot from the supervisor's transcript API.",widthClass:"max-w-5xl",children:e.jsx(ie,{sessionId:V,stream:Q(x),showBadge:!0,showCaption:!0})})]})}function Qe({rows:s}){return s.length===0?null:e.jsxs("section",{"aria-label":"Agents needing you",className:"mb-10",children:[e.jsxs("h2",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:["Needs you (",s.length,")"]}),e.jsx("ol",{className:"mt-3 divide-y divide-rule",children:s.map(({need:n,label:o,slug:l})=>e.jsxs("li",{className:"py-3",children:[e.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[e.jsx(I,{to:`/agents/${encodeURIComponent(l)}`,className:"focus-mark block min-w-0 truncate text-title text-fg hover:text-accent",children:o}),e.jsx($,{tone:Se(n.reason),label:Ce(n.reason)})]}),e.jsx("p",{className:"mt-1 text-body text-fg leading-snug",children:n.detail}),e.jsx("p",{className:"mt-0.5 text-body text-fg-muted leading-snug",children:Ae(n.action)})]},n.name))})]})}function Ze({command:s}){const[n,o]=d.useState("idle"),l=n==="copied"?"Copied":n==="failed"?"Copy failed":"Copy attach";return e.jsx(y,{size:"sm",tone:"quiet",title:s,onClick:()=>{et(s,o)},children:l})}async function et(s,n){try{await navigator.clipboard.writeText(s),n("copied")}catch{n("failed")}}function tt(s){if(s.suspended)return"suspended";switch(s.state){case"active":case"running":return"active";case"detached":return"detached";case"rate-limited":case"rate_limited":case"waiting":return"rate-limited";case"failed":case"closed":case"errored":case"stuck":return"stuck";default:return"idle"}}function st(s){if(s.length===0)return"No agents configured.";const n=new Map;for(const k of s){const p=tt(k);n.set(p,(n.get(p)??0)+1)}const o=[],l=n.get("active")??0,c=n.get("idle")??0,u=n.get("detached")??0,r=n.get("rate-limited")??0,i=n.get("stuck")??0,m=n.get("suspended")??0;return l>0&&o.push(`${l} active`),c>0&&o.push(`${c} idle`),u>0&&o.push(`${u} detached`),r>0&&o.push(`${r} rate-limited`),i>0&&o.push(`${i} stuck`),m>0&&o.push(`${m} suspended`),o.join(", ")+"."}export{ft as AgentsPage,P as agentRowLabel,st as buildAgentSynopsis,Ke as isRunningAgent,Xe as isVisibleUnderRunning,T as stateTone}; diff --git a/internal/api/dashboardspa/dist/assets/AmbientHome-usE4zKNv.js b/internal/api/dashboardspa/dist/assets/AmbientHome-Tal9JCSE.js similarity index 94% rename from internal/api/dashboardspa/dist/assets/AmbientHome-usE4zKNv.js rename to internal/api/dashboardspa/dist/assets/AmbientHome-Tal9JCSE.js index fbc0cb994b..ba3ebe55dc 100644 --- a/internal/api/dashboardspa/dist/assets/AmbientHome-usE4zKNv.js +++ b/internal/api/dashboardspa/dist/assets/AmbientHome-Tal9JCSE.js @@ -1 +1 @@ -import{a as j,j as a,r as c,L as h,D as N,E as S,u as M,b as p,F as A,H as y,I as R}from"./index-C20tCZFz.js";import{P as f}from"./PageHeader-D_D-jYn1.js";function m(e){return e.phase==="approval"||e.phase==="blocked"}const L={agents:"Agents",beads:"Beads",runs:"Runs",mail:"Mail",activity:"Activity",health:"Health"},$={agents:"/agents",beads:"/beads",runs:"/runs",mail:"/mail",activity:"/activity",health:"/health"};function x(e){return L[e]}function C(e){return $[e]}function E(){const e=j();return e.items.length===0?null:a.jsxs("section",{"aria-labelledby":"attention-summary-title",className:"space-y-3",children:[a.jsx("h2",{id:"attention-summary-title",className:"text-headline font-semibold text-fg",children:"Attention"}),a.jsx("ul",{className:"space-y-2",children:e.topItems.map(t=>a.jsxs("li",{className:"text-body text-fg flex items-baseline gap-3",children:[a.jsx(D,{item:t}),a.jsx("span",{className:`text-label uppercase tracking-wider ${_(t.severity)}`,children:x(t.domain)})]},`${t.domain}:${t.id}`))}),e.overflowByDomain.length>0&&a.jsx("p",{className:"text-label uppercase tracking-wider text-fg-muted",children:e.overflowByDomain.map((t,n)=>a.jsxs(c.Fragment,{children:[n>0&&" · ",a.jsxs(h,{to:C(t.domain),className:"hover:text-fg focus-mark",children:[t.total," more in ",x(t.domain)]})]},t.domain))})]})}function D({item:e}){return e.href===void 0?a.jsx("span",{className:"font-medium",children:e.title}):a.jsx(h,{to:e.href,className:"font-medium hover:text-fg focus-mark",children:e.title})}function _(e){switch(e){case"attention":return"text-accent";case"watch":return"text-warn";case"unavailable":return"text-fg-muted"}}function F(e){return e.external.status!=="unavailable"?e.external.label:e.title}function H(e){const t=encodeURIComponent(e.id),n=e.scope.status==="available"?e.scope:null;if(e.health.status==="available"&&e.health.data.stuckNode.status==="available"){const i=new URLSearchParams;return i.set("node",e.health.data.stuckNode.id),n&&(i.set("scope_kind",n.kind),i.set("scope_ref",n.ref)),`/runs/${t}?${i.toString()}`}if(n){const i=new URLSearchParams;return i.set("scope_kind",n.kind),i.set("scope_ref",n.ref),`/runs/${t}?${i.toString()}`}return`/runs/${t}`}function I(e){switch(e){case"needsOperator":return"needs you";case"stalled":return"stalled";default:return e}}function P({rows:e}){return a.jsx("section",{id:"needs-you",children:a.jsx("ul",{className:"mt-2 transition-opacity duration-150 ease-out-quart motion-reduce:transition-none",style:{opacity:e.length===0?0:1},"aria-live":"polite","data-testid":"concern-region",children:e.map(({lane:t,reason:n})=>a.jsxs("li",{className:"text-body text-fg flex items-baseline gap-3",children:[a.jsx(h,{to:H(t),className:"font-medium hover:text-fg focus-mark","data-testid":`concern-row-${t.id}`,children:F(t)}),a.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:I(n)})]},t.id))})})}const g="gascity:home-intro-dismissed",v="FirstRunNote";function T(){const[e,t]=c.useState(()=>N("localStorage",g,v).status==="found");if(e)return null;const n=()=>{t(!0),S("localStorage",g,"1",v)};return a.jsxs("aside",{className:"mt-6 max-w-[70ch]","data-testid":"first-run-note",children:[a.jsx("p",{className:"text-body text-fg-muted",children:"New here? This page is the ambient home for a Gas City workspace: a calm census of the formula runs in flight. Healthy work stays quiet by design; the page speaks up only when a run needs an operator decision. The full record lives in Agents, Beads, Runs, and Mail above."}),a.jsx("button",{type:"button",onClick:n,className:"mt-2 text-label uppercase tracking-wider text-fg-muted hover:text-fg transition-colors duration-150 ease-out-quart focus-mark",children:"Dismiss"})]})}function O({census:e,waitingCount:t,failingCount:n}){const s=e.unverifiable>0?` (of ${e.knownDenominator} known)`:"",r=n===0?`nothing failing${s}`:`${n} failing${s}`;return a.jsxs("p",{className:"text-title tnum text-fg","data-testid":"phase-census",children:[a.jsxs("span",{children:[e.totalInFlight," in flight"]}),a.jsx("span",{"aria-hidden":"true",className:"mx-2 text-fg-faint",children:"·"}),a.jsxs("span",{children:[t," waiting"]}),a.jsx("span",{"aria-hidden":"true",className:"mx-2 text-fg-faint",children:"·"}),a.jsx("span",{className:n>0?"font-semibold text-fg":"","aria-live":"polite","data-testid":"phase-census-failing",children:r})]})}function B(e){const t=Math.floor(e/6e4);if(t<60)return`${t} min`;const n=Math.floor(t/60);return n<24?`${n}h`:`${Math.floor(n/24)}d`}function q(e){if(e.health.status!=="available")return null;const t=e.health.data.stuckNode;if(t.status!=="available")return null;const n=encodeURIComponent(e.id),i=e.scope.status==="available"?e.scope:null,s=new URLSearchParams;return s.set("node",t.id),i&&(s.set("scope_kind",i.kind),s.set("scope_ref",i.ref)),`/runs/${n}?${s.toString()}`}function U(e){return e.external.status!=="unavailable"?e.external.label:e.title}function G(e){return m(e)?"has been waiting on your decision for":"has waited on a review verdict for"}function K({topConcern:e}){const{lane:t,ageMs:n}=e,i=U(t),s=q(t),r=G(t),o=B(n);return a.jsxs("p",{className:"text-body text-fg max-w-[70ch] leading-relaxed","data-testid":"status-sentence",children:[s===null?a.jsx("span",{"data-testid":"status-sentence-token",children:i}):a.jsx(h,{to:s,className:"text-accent font-semibold focus-mark","data-testid":"status-sentence-token",children:i})," ",r," ",o,"."]})}const V="/favicon-calm.svg",W="/favicon-alert.svg",Y=2;function Q(e){const t=document.getElementById("favicon");t instanceof HTMLLinkElement&&(t.href=`${e}?v=${Date.now()}`)}function z({failing:e,cycleKey:t}){const n=c.useRef("calm"),i=c.useRef(0),s=c.useRef(null);c.useEffect(()=>{if(s.current===t)return;s.current=t;const r=n.current,o=e>0?"alert":"calm";if(o===r){i.current=0;return}i.current+=1,!(i.current=b.stalled?"stalled":e>=b.warning?"warning":"fresh"}function Z(e){const t=M();return c.useMemo(()=>{const n=new Map,i=[];for(const s of e){const r=J(s),o=s.health.status==="available"&&s.health.data.phaseConfidence==="known";if(r===null){n.set(s.id,{tier:"unknown",ageMs:0,isStalled:!1});continue}const l=Math.max(0,t-r);if(!o){n.set(s.id,{tier:"unknown",ageMs:l,isStalled:!1});continue}const d=X(l),u=d==="stalled";n.set(s.id,{tier:d,ageMs:l,isStalled:u}),u&&i.push({id:s.id,ageMs:l})}return i.sort((s,r)=>r.ageMs-s.ageMs),{byLane:n,clientStalledLaneIds:i.map(s=>s.id)}},[e,t])}function ee(e,t){const n=[];for(const s of e){if(s.health.status!=="available"||!(s.health.data.phaseConfidence==="known"))continue;const o=t.byLane.get(s.id)?.ageMs??0;s.health.data.thrashingDetected?n.push({lane:s,ageMs:o,priority:2}):t.byLane.get(s.id)?.isStalled&&n.push({lane:s,ageMs:o,priority:1})}if(n.length===0)return;n.sort((s,r)=>r.priority-s.priority||r.ageMs-s.ageMs);const i=n[0];return{lane:i.lane,ageMs:i.ageMs}}function te(e,t,n){const i=[];for(const s of e){if(s.id===n)continue;if(m(s)){i.push({lane:s,reason:"needsOperator"});continue}if(s.health.status!=="available")continue;const r=s.health.data;r.phaseConfidence==="known"&&(r.thrashingDetected||t.byLane.get(s.id)?.isStalled)&&i.push({lane:s,reason:"stalled"})}return i}function se(e){let t=0;for(const n of e)m(n)&&(t+=1);return t}function ne(e){return e===void 0||e.status==="error"?null:{source:e,summary:e.data}}function ae({fresh:e,cityName:t,cycleKey:n,workInProgress:i}){const{summary:s}=e,r=c.useMemo(()=>[...s.lanes,...s.blockedLanes],[s.lanes,s.blockedLanes]),o=Z(r),l=c.useMemo(()=>ee(r,o),[r,o]),d=c.useMemo(()=>te(r,o,l?.lane.id),[r,o,l]),u=s.census.status!=="available"?0:s.census.data.thrashing+o.clientStalledLaneIds.length;z({failing:u,cycleKey:n});const w=i.status==="available"?`, ${i.value} in progress`:"",k=t!==null?`${t}, ${s.totalActive} active${w}`:null;return a.jsxs("section",{children:[a.jsx(f,{title:"Home",synopsis:k}),a.jsx(T,{}),s.census.status!=="available"?a.jsxs("p",{className:"mt-6 text-body text-fg-muted max-w-[70ch]",role:"alert","data-testid":"census-unavailable",children:["Census unavailable: ",s.census.error,"."]}):a.jsxs("div",{className:"mt-6 space-y-6",children:[a.jsx(E,{}),a.jsxs("div",{className:"space-y-4",children:[a.jsx(O,{census:s.census.data,waitingCount:se(r),failingCount:u}),l!==void 0&&a.jsx(K,{topConcern:l}),a.jsx(P,{rows:d})]})]})]})}function ce(){const e=y(),{data:t,loading:n,error:i}=p(`runs:summary:${e??"no-city"}`,A),s=p(`home:work:${e??"no-city"}`,ie),r=ne(t),o=r?.source.fetchedAt??"pre-snapshot";return t===void 0&&n?a.jsxs("section",{children:[a.jsx(f,{title:"Home",synopsis:null}),a.jsx("p",{className:"mt-6 text-body text-fg-muted",children:"Loading…"})]}):t===void 0&&i!==null?a.jsxs("section",{children:[a.jsx(f,{title:"Home",synopsis:null}),a.jsx("p",{className:"mt-6 text-body text-accent",role:"alert","data-testid":"snapshot-error",children:i})]}):r===null?a.jsxs("section",{children:[a.jsx(f,{title:"Home",synopsis:null}),a.jsx("p",{className:"mt-6 text-body text-accent",role:"alert","data-testid":"runs-source-error",children:"Run data is unavailable."})]}):a.jsx(ae,{fresh:r,cityName:e,cycleKey:o,workInProgress:s.data??{status:"unavailable",source:"work",error:"loading"}})}async function ie(){const e=y();if(e===null)return{status:"unavailable",source:"work",error:"active city unavailable"};try{return{status:"available",value:(await R().cityStatus(e)).work.in_progress}}catch(t){return{status:"unavailable",source:"work",error:t instanceof Error?t.message:"work unavailable"}}}export{ce as AmbientHomePage}; +import{a as j,j as a,r as c,L as h,C as N,D as S,u as M,b as p,E as A,F as y,H as R}from"./index-CJ6RRl2D.js";import{P as f}from"./PageHeader-4s_Bnmfl.js";function m(e){return e.phase==="approval"||e.phase==="blocked"}const C={agents:"Agents",beads:"Beads",runs:"Runs",mail:"Mail",activity:"Activity",health:"Health"},L={agents:"/agents",beads:"/beads",runs:"/runs",mail:"/mail",activity:"/activity",health:"/health"};function x(e){return C[e]}function $(e){return L[e]}function E(){const e=j();return e.items.length===0?null:a.jsxs("section",{"aria-labelledby":"attention-summary-title",className:"space-y-3",children:[a.jsx("h2",{id:"attention-summary-title",className:"text-headline font-semibold text-fg",children:"Attention"}),a.jsx("ul",{className:"space-y-2",children:e.topItems.map(t=>a.jsxs("li",{className:"text-body text-fg flex items-baseline gap-3",children:[a.jsx(D,{item:t}),a.jsx("span",{className:`text-label uppercase tracking-wider ${_(t.severity)}`,children:x(t.domain)})]},`${t.domain}:${t.id}`))}),e.overflowByDomain.length>0&&a.jsx("p",{className:"text-label uppercase tracking-wider text-fg-muted",children:e.overflowByDomain.map((t,n)=>a.jsxs(c.Fragment,{children:[n>0&&" · ",a.jsxs(h,{to:$(t.domain),className:"hover:text-fg focus-mark",children:[t.total," more in ",x(t.domain)]})]},t.domain))})]})}function D({item:e}){return e.href===void 0?a.jsx("span",{className:"font-medium",children:e.title}):a.jsx(h,{to:e.href,className:"font-medium hover:text-fg focus-mark",children:e.title})}function _(e){switch(e){case"attention":return"text-accent";case"watch":return"text-warn";case"unavailable":return"text-fg-muted"}}function F(e){return e.external.status!=="unavailable"?e.external.label:e.title}function H(e){const t=encodeURIComponent(e.id),n=e.scope.status==="available"?e.scope:null;if(e.health.status==="available"&&e.health.data.stuckNode.status==="available"){const i=new URLSearchParams;return i.set("node",e.health.data.stuckNode.id),n&&(i.set("scope_kind",n.kind),i.set("scope_ref",n.ref)),`/runs/${t}?${i.toString()}`}if(n){const i=new URLSearchParams;return i.set("scope_kind",n.kind),i.set("scope_ref",n.ref),`/runs/${t}?${i.toString()}`}return`/runs/${t}`}function I(e){switch(e){case"needsOperator":return"needs you";case"stalled":return"stalled";default:return e}}function P({rows:e}){return a.jsx("section",{id:"needs-you",children:a.jsx("ul",{className:"mt-2 transition-opacity duration-150 ease-out-quart motion-reduce:transition-none",style:{opacity:e.length===0?0:1},"aria-live":"polite","data-testid":"concern-region",children:e.map(({lane:t,reason:n})=>a.jsxs("li",{className:"text-body text-fg flex items-baseline gap-3",children:[a.jsx(h,{to:H(t),className:"font-medium hover:text-fg focus-mark","data-testid":`concern-row-${t.id}`,children:F(t)}),a.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:I(n)})]},t.id))})})}const g="gascity:home-intro-dismissed",v="FirstRunNote";function T(){const[e,t]=c.useState(()=>N("localStorage",g,v).status==="found");if(e)return null;const n=()=>{t(!0),S("localStorage",g,"1",v)};return a.jsxs("aside",{className:"mt-6 max-w-[70ch]","data-testid":"first-run-note",children:[a.jsx("p",{className:"text-body text-fg-muted",children:"New here? This page is the ambient home for a Gas City workspace: a calm census of the formula runs in flight. Healthy work stays quiet by design; the page speaks up only when a run needs an operator decision. The full record lives in Agents, Beads, Runs, and Mail above."}),a.jsx("button",{type:"button",onClick:n,className:"mt-2 text-label uppercase tracking-wider text-fg-muted hover:text-fg transition-colors duration-150 ease-out-quart focus-mark",children:"Dismiss"})]})}function O({census:e,waitingCount:t,failingCount:n}){const s=e.unverifiable>0?` (of ${e.knownDenominator} known)`:"",r=n===0?`nothing failing${s}`:`${n} failing${s}`;return a.jsxs("p",{className:"text-title tnum text-fg","data-testid":"phase-census",children:[a.jsxs("span",{children:[e.totalInFlight," in flight"]}),a.jsx("span",{"aria-hidden":"true",className:"mx-2 text-fg-faint",children:"·"}),a.jsxs("span",{children:[t," waiting"]}),a.jsx("span",{"aria-hidden":"true",className:"mx-2 text-fg-faint",children:"·"}),a.jsx("span",{className:n>0?"font-semibold text-fg":"","aria-live":"polite","data-testid":"phase-census-failing",children:r})]})}function B(e){const t=Math.floor(e/6e4);if(t<60)return`${t} min`;const n=Math.floor(t/60);return n<24?`${n}h`:`${Math.floor(n/24)}d`}function q(e){if(e.health.status!=="available")return null;const t=e.health.data.stuckNode;if(t.status!=="available")return null;const n=encodeURIComponent(e.id),i=e.scope.status==="available"?e.scope:null,s=new URLSearchParams;return s.set("node",t.id),i&&(s.set("scope_kind",i.kind),s.set("scope_ref",i.ref)),`/runs/${n}?${s.toString()}`}function U(e){return e.external.status!=="unavailable"?e.external.label:e.title}function G(e){return m(e)?"has been waiting on your decision for":"has waited on a review verdict for"}function K({topConcern:e}){const{lane:t,ageMs:n}=e,i=U(t),s=q(t),r=G(t),o=B(n);return a.jsxs("p",{className:"text-body text-fg max-w-[70ch] leading-relaxed","data-testid":"status-sentence",children:[s===null?a.jsx("span",{"data-testid":"status-sentence-token",children:i}):a.jsx(h,{to:s,className:"text-accent font-semibold focus-mark","data-testid":"status-sentence-token",children:i})," ",r," ",o,"."]})}const V="/favicon-calm.svg",W="/favicon-alert.svg",Y=2;function Q(e){const t=document.getElementById("favicon");t instanceof HTMLLinkElement&&(t.href=`${e}?v=${Date.now()}`)}function z({failing:e,cycleKey:t}){const n=c.useRef("calm"),i=c.useRef(0),s=c.useRef(null);c.useEffect(()=>{if(s.current===t)return;s.current=t;const r=n.current,o=e>0?"alert":"calm";if(o===r){i.current=0;return}i.current+=1,!(i.current=b.stalled?"stalled":e>=b.warning?"warning":"fresh"}function Z(e){const t=M();return c.useMemo(()=>{const n=new Map,i=[];for(const s of e){const r=J(s),o=s.health.status==="available"&&s.health.data.phaseConfidence==="known";if(r===null){n.set(s.id,{tier:"unknown",ageMs:0,isStalled:!1});continue}const l=Math.max(0,t-r);if(!o){n.set(s.id,{tier:"unknown",ageMs:l,isStalled:!1});continue}const d=X(l),u=d==="stalled";n.set(s.id,{tier:d,ageMs:l,isStalled:u}),u&&i.push({id:s.id,ageMs:l})}return i.sort((s,r)=>r.ageMs-s.ageMs),{byLane:n,clientStalledLaneIds:i.map(s=>s.id)}},[e,t])}function ee(e,t){const n=[];for(const s of e){if(s.health.status!=="available"||!(s.health.data.phaseConfidence==="known"))continue;const o=t.byLane.get(s.id)?.ageMs??0;s.health.data.thrashingDetected?n.push({lane:s,ageMs:o,priority:2}):t.byLane.get(s.id)?.isStalled&&n.push({lane:s,ageMs:o,priority:1})}if(n.length===0)return;n.sort((s,r)=>r.priority-s.priority||r.ageMs-s.ageMs);const i=n[0];return{lane:i.lane,ageMs:i.ageMs}}function te(e,t,n){const i=[];for(const s of e){if(s.id===n)continue;if(m(s)){i.push({lane:s,reason:"needsOperator"});continue}if(s.health.status!=="available")continue;const r=s.health.data;r.phaseConfidence==="known"&&(r.thrashingDetected||t.byLane.get(s.id)?.isStalled)&&i.push({lane:s,reason:"stalled"})}return i}function se(e){let t=0;for(const n of e)m(n)&&(t+=1);return t}function ne(e){return e===void 0||e.status==="error"?null:{source:e,summary:e.data}}function ae({fresh:e,cityName:t,cycleKey:n,workInProgress:i}){const{summary:s}=e,r=c.useMemo(()=>[...s.lanes,...s.blockedLanes],[s.lanes,s.blockedLanes]),o=Z(r),l=c.useMemo(()=>ee(r,o),[r,o]),d=c.useMemo(()=>te(r,o,l?.lane.id),[r,o,l]),u=s.census.status!=="available"?0:s.census.data.thrashing+o.clientStalledLaneIds.length;z({failing:u,cycleKey:n});const w=i.status==="available"?`, ${i.value} in progress`:"",k=t!==null?`${t}, ${s.totalActive} active${w}`:null;return a.jsxs("section",{children:[a.jsx(f,{title:"Home",synopsis:k}),a.jsx(T,{}),s.census.status!=="available"?a.jsxs("p",{className:"mt-6 text-body text-fg-muted max-w-[70ch]",role:"alert","data-testid":"census-unavailable",children:["Census unavailable: ",s.census.error,"."]}):a.jsxs("div",{className:"mt-6 space-y-6",children:[a.jsx(E,{}),a.jsxs("div",{className:"space-y-4",children:[a.jsx(O,{census:s.census.data,waitingCount:se(r),failingCount:u}),l!==void 0&&a.jsx(K,{topConcern:l}),a.jsx(P,{rows:d})]})]})]})}function ce(){const e=y(),{data:t,loading:n,error:i}=p(`runs:summary:${e??"no-city"}`,A),s=p(`home:work:${e??"no-city"}`,ie),r=ne(t),o=r?.source.fetchedAt??"pre-snapshot";return t===void 0&&n?a.jsxs("section",{children:[a.jsx(f,{title:"Home",synopsis:null}),a.jsx("p",{className:"mt-6 text-body text-fg-muted",children:"Loading…"})]}):t===void 0&&i!==null?a.jsxs("section",{children:[a.jsx(f,{title:"Home",synopsis:null}),a.jsx("p",{className:"mt-6 text-body text-accent",role:"alert","data-testid":"snapshot-error",children:i})]}):r===null?a.jsxs("section",{children:[a.jsx(f,{title:"Home",synopsis:null}),a.jsx("p",{className:"mt-6 text-body text-accent",role:"alert","data-testid":"runs-source-error",children:"Run data is unavailable."})]}):a.jsx(ae,{fresh:r,cityName:e,cycleKey:o,workInProgress:s.data??{status:"unavailable",source:"work",error:"loading"}})}async function ie(){const e=y();if(e===null)return{status:"unavailable",source:"work",error:"active city unavailable"};try{return{status:"available",value:(await R().cityStatus(e)).work.in_progress}}catch(t){return{status:"unavailable",source:"work",error:t instanceof Error?t.message:"work unavailable"}}}export{ce as AmbientHomePage}; diff --git a/internal/api/dashboardspa/dist/assets/BeadDetailModal-3OfdZfdZ.js b/internal/api/dashboardspa/dist/assets/BeadDetailModal-3OfdZfdZ.js new file mode 100644 index 0000000000..fc91cded02 --- /dev/null +++ b/internal/api/dashboardspa/dist/assets/BeadDetailModal-3OfdZfdZ.js @@ -0,0 +1 @@ +import{r as h,u as H,$ as K,a0 as O,I as V,H as E,a1 as q,z as W,j as n,S as Y,a2 as Z,L as X,B as J}from"./index-CJ6RRl2D.js";import{f as Q}from"./format-fte2CeYD.js";import{F as x}from"./Field-9mEFYSnz.js";import{a as P,L as ee}from"./LiveSessionPeek-D86UU9cf.js";import{M as U}from"./constants-BJUwiA6r.js";import{f as D}from"./time-D9v0saHV.js";function te(e,t){if(e.length===0||t.length===0)return null;const s=t.filter(r=>r.state==="active");return F(e,s)??F(e,t)}function F(e,t){for(const s of t)if(se(s,e))return s;return null}function se(e,t){return e.alias===t||e.pool===t||e.alias!==void 0&&A(e.alias,["/","."])===t||e.session_name!==void 0&&A(e.session_name,["__","--"])===t}function A(e,t){let s=-1,r=0;for(const i of t){const l=e.lastIndexOf(i);l>s&&(s=l,r=i.length)}return s<0?e:e.slice(s+r)}const ne=/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;function re(e,t,s){return`${e}:${s}:${t}`}function b(e,t,s){e?.(t,s)}const ie=/^pr\/(\d{1,9})$/,le=/^issue\/(\d{1,9})$/;function oe(e){const t=e.trim();if(t.length===0)return{ok:!1,error:"empty ref"};const s=ie.exec(t);if(s?.[1])return{ok:!0,type:"github_pr",value:s[1]};const r=le.exec(t);return r?.[1]?{ok:!0,type:"github_issue",value:r[1]}:ne.test(t)?{ok:!0,type:"bead",value:t}:{ok:!1,error:"unrecognised ref"}}function $(e){if(typeof e!="string")return null;const t=e.trim();return/^https?:\/\//i.test(t)?t:null}function v(e,t,s){return re(e,t,s)}function ae(e){return{key:v("bead",e.id,e.scope),type:"bead",ref:e.id}}function _(e,t){const s=e.stats.get(t);if(s)return s;const r={relation:t,resolved:0,unresolved:0,nCandidates:0};return e.stats.set(t,r),r}function y(e,t){e.nodesByKey.has(t.key)||(e.nodesByKey.set(t.key,t),e.view.nodes.push(t))}function k(e,t,s,r,i,l){e.view.edges.push({from:t,to:s,relation:r,provenance:i,resolved:l})}function N(e,t,s,r,i){const l=ae(s);y(e,{...l,title:s.title,status:s.status,url:null,fetchedAt:i,unresolved:!1}),k(e,t,l.key,r,"supervisor",!0),_(e,r).resolved+=1,b(e.recorder,r,"resolved")}function ue(e,t){return{focus:e,nodes:[],edges:[],stats:[],partial:!1,generatedAt:t,asOf:null}}function M(e,t){return e===null?t:t===null||Date.parse(e)<=Date.parse(t)?e:t}function ce(e,t,s={}){const i=(s.now??(()=>new Date))().toISOString(),l=s.supervisorFetchedAt??null,u=s.githubFetchedAt??null,a=de(e,t),o=ue(a.focus,i);o.partial=s.partial??!1;const c={view:o,nodesByKey:new Map,stats:new Map,recorder:s.recorder??(()=>{})};if(y(c,a.focusNode),!a.focusResolved)return o.partial=!0,L(c,l,u),o;const f=a.focusNode.key;if(a.beadFocus)for(const m of a.beads)pe(c,m,f,e,l,u);else for(const m of a.beads)N(c,f,m,"bead",l);return L(c,l,u),o}function de(e,t){if(t.type==="github_pr"||t.type==="github_issue"){const u=t.type==="github_pr"?"github_pr":"github_issue",a=t.type==="github_pr"?`pr/${t.value}`:`issue/${t.value}`,c=(t.type==="github_pr"?e.beadsForPr.get(t.value)??[]:e.beadsForIssue.get(t.value)??[]).map(m=>e.beads.get(m)).filter(m=>m!==void 0),f={key:v(u,t.value,"github"),type:u,ref:a};return{focus:f,focusNode:{...f,title:null,status:null,url:null,fetchedAt:null,unresolved:c.length===0,...c.length>1?{candidateCount:c.length}:{}},beads:c,focusResolved:c.length>0,beadFocus:!1}}const s=e.beads.get(t.value)??e.allBeads.get(t.value);if(s!==void 0){const u=fe(s);return{focus:u,focusNode:{...u,title:s.title,status:s.status,url:null,fetchedAt:null,unresolved:!1},beads:s.superseded?[]:[s],focusResolved:!0,beadFocus:!0}}const r=e.beadsForSession.get(t.value)??[],i=e.sessions.has(t.value);if(r.length>0||i){const u={key:v("session",t.value,"session"),type:"session",ref:t.value},a=r.map(c=>e.beads.get(c)).filter(c=>c!==void 0),o=i||a.length>0;return{focus:u,focusNode:{...u,title:e.sessions.get(t.value)?.title??null,status:e.sessions.get(t.value)?.state??null,url:null,fetchedAt:null,unresolved:!o},beads:a,focusResolved:o,beadFocus:!1}}const l={key:v("bead",t.value,"unknown"),type:"bead",ref:t.value};return{focus:l,focusNode:{...l,title:null,status:null,url:null,fetchedAt:null,unresolved:!0},beads:[],focusResolved:!1,beadFocus:!0}}function fe(e){return{key:v("bead",e.id,e.scope),type:"bead",ref:e.id}}function pe(e,t,s,r,i,l){if(t.parentBeadId){const a=r.beads.get(t.parentBeadId);a?N(e,s,a,"parent",i):me(e,s,t.parentBeadId,"parent")}const u=(r.childrenOf.get(t.id)??[]).filter(a=>a!==t.id);for(const a of u){const o=r.beads.get(a);o&&N(e,s,o,"child",i)}if(t.moleculeId){const a=(r.membersOfMolecule.get(t.moleculeId)??[]).filter(o=>o!==t.id&&o!==t.moleculeId);if(t.moleculeId!==t.id){const o=r.beads.get(t.moleculeId);o&&N(e,s,o,"molecule",i)}for(const o of a){const c=r.beads.get(o);c&&N(e,s,c,"molecule",i)}}if(t.prNumber&&B(e,s,"github_pr",`pr/${t.prNumber}`,t.prNumber,$(t.prUrl),"pr","supervisor",l),t.issueNumber&&B(e,s,"github_issue",`issue/${t.issueNumber}`,t.issueNumber,$(t.issueUrl),"issue","supervisor",l),t.sessionId){const a=r.sessions.get(t.sessionId),o={key:v("session",t.sessionId,"session"),type:"session",ref:t.sessionId};a?(y(e,{...o,title:a.title??a.alias??t.sessionName??null,status:a.state??null,url:null,fetchedAt:i,unresolved:!1}),k(e,s,o.key,"session","supervisor",!0),_(e,"session").resolved+=1,b(e.recorder,"session","resolved")):(y(e,{...o,title:t.sessionName??null,status:null,url:null,fetchedAt:i,unresolved:!0}),k(e,s,o.key,"session","supervisor",!1),_(e,"session").unresolved+=1,b(e.recorder,"session","unresolved"))}}function B(e,t,s,r,i,l,u,a,o){const c=v(s,i,"github");y(e,{key:c,type:s,ref:r,title:null,status:null,url:l,fetchedAt:o,unresolved:!0}),k(e,t,c,u,a,!1),_(e,u).unresolved+=1,b(e.recorder,u,"unresolved")}function me(e,t,s,r){const i=v("bead",s,"unknown");y(e,{key:i,type:"bead",ref:s,title:null,status:null,url:null,fetchedAt:null,unresolved:!0}),k(e,t,i,r,"supervisor",!1),_(e,r).unresolved+=1,b(e.recorder,r,"unresolved")}function L(e,t,s){e.view.stats=[...e.stats.values()].sort((i,l)=>i.relation.localeCompare(l.relation));let r=null;for(const i of e.view.nodes)r=M(r,i.fetchedAt);e.view.asOf=r??M(t,s)}function p(e,t){const s=e.metadata?.[t];if(typeof s=="string"){const r=s.trim();return r.length>0?r:void 0}if(typeof s=="number"&&Number.isFinite(s))return String(s)}function he(e,t){const s=e.metadata?.[t];if(typeof s=="number"&&Number.isInteger(s)&&s>=0)return s;if(typeof s=="string"&&/^\d+$/.test(s.trim()))return Number.parseInt(s.trim(),10)}const ge=["gc.scope_ref","scope_ref","scope_id"],xe=["gc.scope_kind","scope_kind"];function ve(e,t){let s;for(const i of ge){const l=p(e,i);if(l!==void 0){s=l;break}}let r;for(const i of xe){const l=p(e,i);if(l!==void 0){r=l;break}}return s===void 0?`city:${t}`:`${r??"rig"}:${s}`}const ye=/^github-pr:[^/]+\/[^/]+\/(\d+)$/,je=/\/(?:pull\/)?(\d+)(?:[/?#]|$)/;function Ne(e){const t=p(e,"evidence.pr_url"),s=p(e,"evidence.pr_number"),r=p(e,"evidence.artifact_path"),i=p(e,"pr_review.pr_number"),l=p(e,"pr_review.pr_url"),u=r?.match(ye),a=t?.match(je),o=s??u?.[1]??a?.[1]??i??void 0,c=t??l??void 0,f={};return o!==void 0&&(f.prNumber=o),c!==void 0&&(f.prUrl=c),f}function be(e,t){const{prNumber:s,prUrl:r}=Ne(e),i={id:e.id,title:e.title,status:e.status,scope:ve(e,t),superseded:!1},l={parentBeadId:p(e,"gc.parent_bead_id"),rootBeadId:p(e,"gc.root_bead_id"),moleculeId:p(e,"molecule_id"),prNumber:s,prUrl:r,issueNumber:p(e,"bugflow.github_issue_number")??p(e,"design_review.github_issue_number"),issueUrl:p(e,"bugflow.github_issue_url")??p(e,"design_review.github_issue_url"),sessionId:p(e,"session_id"),sessionName:p(e,"session_name"),stepId:p(e,"gc.step_id"),attempt:he(e,"gc.attempt")};for(const[u,a]of Object.entries(l))a!==void 0&&Object.assign(i,{[u]:a});return i}function T(e){return`${e.moleculeId}\0${e.stepId}`}function _e(e){const t=new Map;for(const s of e){if(s.moleculeId===void 0||s.stepId===void 0||s.attempt===void 0)continue;const r=T(s),i=t.get(r);(i===void 0||s.attempt>i)&&t.set(r,s.attempt)}for(const s of e){if(s.moleculeId===void 0||s.stepId===void 0||s.attempt===void 0)continue;const r=t.get(T(s));r!==void 0&&s.attemptbe(d,s));_e(r);const i=new Map,l=new Map,u=new Map,a=new Map,o=new Map,c=new Map,f=new Map;for(const d of r)i.set(d.id,d),!d.superseded&&(l.set(d.id,d),d.parentBeadId&&j(u,d.parentBeadId,d.id),d.moleculeId&&j(a,d.moleculeId,d.id),d.prNumber&&j(o,d.prNumber,d.id),d.issueNumber&&j(c,d.issueNumber,d.id),d.sessionId&&j(f,d.sessionId,d.id));const m=new Map;for(const d of t)m.set(d.id,d);return{beads:l,allBeads:i,childrenOf:u,membersOfMolecule:a,beadsForPr:o,beadsForIssue:c,beadsForSession:f,sessions:m}}function we(e,t,s=null){const[r,i]=h.useState(s),[l,u]=h.useState(!1),[a,o]=h.useState(null),[c,f]=h.useState(!1),m=H();return h.useEffect(()=>{if(!e||!t)return;if(s&&s.id===t&&s.description!==void 0){i(s),o(null),f(!1);return}i(s?.id===t?s:null),u(!0),o(null),f(!1);let d=!1;return(async()=>{try{const g=await K(t);d||i(g)}catch(g){if(d)return;g instanceof O&&g.status===404?f(!0):o(Se(g))}finally{d||u(!1)}})(),()=>{d=!0}},[e,t,s]),{bead:r,loading:l,error:a,notFound:c,now:m}}function Se(e){return e instanceof O?e.status===void 0?e.message:`${e.status} ${e.message}`:e instanceof Error?e.message:"fetch failed"}function Ie(e){return e.partial===!0||(e.partial_errors?.length??0)>0||(e.next_cursor?.length??0)>0}function Re(e,t){return Ie(e)||typeof e.total=="number"&&e.total>t}const Ee=1e3;async function Fe(e){const t=oe(e);if(!t.ok)throw new Error(t.error);const s=V("load supervisor entity links"),r=new Date().toISOString(),i=await E().listBeads(s,{limit:Ee}),l=Ae(i.items??[]);let u=Re(i,l.length),a=[];try{const c=await E().listSessions(s);a=q(c),u||=Me(c)}catch{u=!0}const o=ke(l,a,s);return ce(o,t,{partial:u,supervisorFetchedAt:r,githubFetchedAt:null})}function Ae(e){return e.map($e)}function $e(e){const t={id:e.id,title:e.title,status:e.status,issue_type:e.issue_type,priority:e.priority??null,created_at:e.created_at};return e.description!==void 0&&(t.description=e.description),e.assignee!==void 0&&(t.assignee=e.assignee),Array.isArray(e.labels)&&(t.labels=e.labels),e.metadata!==void 0&&(t.metadata=e.metadata),e.ref!==void 0&&(t.ref=e.ref),e.parent!==void 0&&(t.parent=e.parent),e.from!==void 0&&(t.from=e.from),e.ephemeral!==void 0&&(t.ephemeral=e.ephemeral),e.needs!==void 0&&(t.needs=e.needs),e.dependencies!==void 0&&(t.dependencies=e.dependencies),e.updated_at!==void 0&&(t.updated_at=e.updated_at),t}function Me(e){return e.partial===!0||(e.partial_errors?.length??0)>0}function Be(e){const[t,s]=h.useState(null),[r,i]=h.useState(!1),[l,u]=h.useState(null);return h.useEffect(()=>{if(e===null||e.length===0){s(null),u(null),i(!1);return}let a=!1;return i(!0),u(null),(async()=>{try{const o=await Fe(e);a||s(o)}catch(o){if(a)return;u(W(o,"related entities failed")),s(null)}finally{a||i(!1)}})(),()=>{a=!0}},[e]),{view:t,loading:r,error:l}}function Le(e){const t=e.metadata;if(!t)return{};const s={};return t["gc.kind"]&&(s.kind=t["gc.kind"]),t["gc.source_bead_id"]&&(s.originBeadId=t["gc.source_bead_id"]),t["gc.formula_contract"]&&(s.formulaContract=t["gc.formula_contract"]),t["gc.run_target"]?s.runTarget=t["gc.run_target"]:t["gc.routed_to"]&&(s.runTarget=t["gc.routed_to"]),s}function Te(e,t){return t.kind==="run"?"template":e.issue_type==="molecule"?"wisp":"work"}function Ce({bead:e}){const t=Le(e),s=Te(e,t);return n.jsxs("div",{className:"space-y-8",children:[s==="template"&&n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:"Formula template"}),n.jsxs("p",{className:"text-body text-fg-muted max-w-prose",children:["This bead is a recipe, not actionable work. Every"," ",e.ref?n.jsx("code",{className:"text-fg-muted",children:e.ref}):"wisp"," instance is instantiated from this template. The ",n.jsx("span",{className:"text-fg-muted",children:"in_progress"})," ","status is the gc-system convention for ",'"',"available for instantiation",'"'," — do not act on it, nudge it, or close it."]})]}),s==="wisp"&&n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:"Formula instance"}),n.jsxs("p",{className:"text-body text-fg-muted max-w-prose",children:["One run of the"," ",e.title?n.jsx("code",{className:"text-fg-muted",children:e.title}):"formula"," recipe."]})]}),n.jsxs("dl",{className:"grid grid-cols-2 sm:grid-cols-4 gap-x-8 gap-y-5",children:[n.jsx(x,{label:"Status",children:n.jsx(Y,{tone:Z(e.status),label:e.status})}),n.jsx(x,{label:"Type",children:e.issue_type}),n.jsx(x,{label:"Assignee",children:e.assignee||"·"}),n.jsx(x,{label:"Created",children:n.jsx("span",{className:"tnum",children:Q(e.created_at)})})]}),s==="template"&&(t.formulaContract||t.originBeadId||t.runTarget)&&n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:"Template origin"}),n.jsx("p",{className:"text-body text-fg-muted max-w-prose mb-4",children:"Where this formula came from, kept for traceability. The origin bead and target may be stale; the formula itself is now used wherever the pool dispatches it."}),n.jsxs("dl",{className:"grid grid-cols-2 sm:grid-cols-3 gap-x-8 gap-y-3",children:[t.formulaContract&&n.jsx(x,{label:"Contract",children:n.jsx("code",{className:"text-fg-muted",children:t.formulaContract})}),e.ref&&n.jsx(x,{label:"Ref",children:n.jsx("code",{className:"text-fg-muted",children:e.ref})}),t.originBeadId&&n.jsx(x,{label:"Origin bead",children:n.jsx("code",{className:"text-fg-muted",children:t.originBeadId})}),t.runTarget&&n.jsx(x,{label:"Origin target",children:n.jsx("span",{className:"text-fg-muted truncate",title:t.runTarget,children:t.runTarget})})]})]}),Array.isArray(e.labels)&&e.labels.length>0&&n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:"Labels"}),n.jsx("div",{className:"flex flex-wrap gap-x-3 gap-y-1",children:e.labels.map(r=>n.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:r},r))})]}),n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:s==="template"?"Recipe":"Description"}),e.description&&e.description.length>0?n.jsx("pre",{className:"text-body whitespace-pre-wrap leading-relaxed text-fg font-sans",children:e.description}):n.jsx("p",{className:"text-body text-fg-muted italic",children:"No description."})]})]})}function Oe({node:e,onOpenBead:t}){const{deps:s,blocks:r}=e,i=s.length>0||r.length>0;return n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:"Dependencies"}),i?n.jsxs("div",{className:"space-y-6",children:[s.length>0&&n.jsxs("div",{children:[n.jsxs("p",{className:"text-label uppercase tracking-wider text-fg-muted mb-2",children:["Needs ",n.jsx("span",{className:"tnum",children:s.length})]}),n.jsx("ul",{className:"space-y-1",children:s.map(l=>n.jsx(C,{relation:l.kind==="needs"?null:l.kind,targetId:l.id,targetTitle:l.bead?.title??null,...l.bead&&t?{onOpenBead:t}:{}},`needs-${l.id}`))})]}),r.length>0&&n.jsxs("div",{children:[n.jsxs("p",{className:"text-label uppercase tracking-wider text-fg-muted mb-2",children:["Blocks ",n.jsx("span",{className:"tnum",children:r.length})]}),n.jsx("ul",{className:"space-y-1",children:r.map(l=>n.jsx(C,{relation:null,targetId:l.id,targetTitle:l.title,...t?{onOpenBead:t}:{}},`blocks-${l.id}`))})]})]}):n.jsx("p",{className:"text-body text-fg-muted italic",children:"No dependencies."})]})}function C({relation:e,targetId:t,targetTitle:s,onOpenBead:r}){const i=n.jsxs(n.Fragment,{children:[e&&n.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-faint",children:[e," "]}),n.jsx("span",{className:"tnum text-fg-muted",children:t}),s&&n.jsxs("span",{className:"text-fg",children:[" · ",s]})]});return n.jsx("li",{className:"text-body leading-snug",children:r?n.jsx("button",{type:"button",onClick:()=>r(t),className:"text-left text-fg-muted hover:text-fg focus-mark rounded-sm",title:`Open ${t}`,children:i}):n.jsxs("span",{title:"Outside the fetched window",children:[i," ",n.jsx("span",{className:"text-warn text-label uppercase tracking-wider",children:"unresolved"})]})})}function Pe({open:e,onClose:t,session:s,beadTitle:r}){const i=P(s);return n.jsx(U,{open:e,onClose:t,title:r,caption:s===null?"No live session resolved for this bead.":i?"Live transcript from the supervisor's session stream.":"Snapshot from the supervisor's transcript API.",widthClass:"max-w-5xl",children:n.jsx(ee,{sessionId:s?.id??null,stream:i,showBadge:!0,showCaption:!0})})}const Ue=6,De=3600*1e3,ze=3,Ge=["bead","formula_run","session","github_pr","github_issue","order_run"],He={bead:"Beads",session:"Sessions",github_pr:"Pull requests",github_issue:"Issues",formula_run:"Formula runs",order_run:"Order runs"};function Ke({view:e,loading:t,error:s,now:r,onOpenBead:i}){const[l,u]=h.useState(!1),a=h.useMemo(()=>Je(e),[e]),o=h.useMemo(()=>Xe(e),[e]),c=o.unresolved>=ze;return n.jsxs("section",{className:"mt-12",children:[n.jsxs("header",{className:"flex items-baseline justify-between mb-4 gap-3",children:[n.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Related"}),n.jsxs("div",{className:"flex items-baseline gap-3 min-w-0",children:[e&&e.asOf&&n.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum shrink-0",children:["as of ",D(e.asOf,r)]}),n.jsx(Ve,{loading:t,counts:o,showMark:c})]})]}),s!==null?n.jsx("p",{className:"text-body text-accent",role:"alert",children:s}):t&&e===null?n.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading related entities."}):e===null||a.length===0?n.jsx("p",{className:"text-body text-fg-muted italic",children:"No related entities."}):n.jsxs(n.Fragment,{children:[e.partial&&n.jsx("p",{className:"text-label uppercase tracking-wider text-warn mb-4",role:"status",children:"Partial: some sources did not load. Links may be incomplete."}),n.jsx("button",{type:"button",onClick:()=>u(f=>!f),className:"text-label uppercase tracking-wider text-fg-faint hover:text-fg focus-mark mb-4","aria-expanded":l,children:l?"Hide detail":"Show detail"}),l&&n.jsx("div",{className:"space-y-8",children:a.map(f=>n.jsx(qe,{type:f.type,rows:f.rows,now:r,...i!==void 0?{onOpenBead:i}:{}},f.type))})]})]})}function Ve({loading:e,counts:t,showMark:s}){if(e)return n.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint",children:"·"});const r=[];t.resolved>0&&r.push(`${t.resolved} resolved`),t.unresolved>0&&r.push(`${t.unresolved} unresolved`),t.candidates>0&&r.push(`${t.candidates} candidates`);const i=r.length>0?r.join(", "):"none";return n.jsxs("span",{className:`text-label uppercase tracking-wider tnum truncate ${s?"text-accent":"text-fg-faint"}`,children:[s&&n.jsx("span",{"aria-hidden":!0,children:"■ "}),i]})}function qe({type:e,rows:t,now:s,onOpenBead:r}){const i=t.slice(0,Ue),l=t.length-i.length;return n.jsxs("div",{children:[n.jsxs("header",{className:"flex items-baseline justify-between mb-2",children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint",children:He[e]}),n.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:t.length})]}),n.jsx("ul",{className:"space-y-2",children:i.map(u=>n.jsx(We,{row:u,now:s,...r!==void 0?{onOpenBead:r}:{}},`${u.relation}\0${u.node.key}`))}),l>0&&n.jsxs("p",{className:"text-label uppercase tracking-wider text-fg-faint mt-2",children:["+ ",l," more"]})]})}function We({row:e,now:t,onOpenBead:s}){const{node:r,relation:i}=e,l=Qe(r.fetchedAt,t),u=r.title??r.ref,a=r.unresolved||l;return n.jsxs("li",{className:"flex items-baseline gap-3 min-w-0",children:[n.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint shrink-0 w-20 truncate",children:i}),n.jsx("span",{className:"min-w-0 flex-1 truncate",children:n.jsx(Ye,{node:r,label:u,dimmed:a,...s!==void 0?{onOpenBead:s}:{}})}),n.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum shrink-0",children:r.unresolved?Ze(r):r.fetchedAt?D(r.fetchedAt,t):r.status??"·"})]})}function Ye({node:e,label:t,dimmed:s,onOpenBead:r}){const i=`text-body text-left truncate min-w-0 focus-mark ${s?"text-fg-muted":"text-fg hover:text-accent"}`;return e.type==="bead"&&!e.unresolved&&r?n.jsx("button",{type:"button",onClick:()=>r(e.ref),className:i,title:`Open ${e.ref}`,children:t}):e.type==="session"&&!e.unresolved?n.jsx(X,{to:`/agents/${encodeURIComponent(e.ref)}`,className:i,children:t}):e.url?n.jsxs("a",{href:e.url,target:"_blank",rel:"noreferrer noopener",className:i,title:e.url,children:[t," ",n.jsx("span",{"aria-hidden":!0,children:"↗"})]}):n.jsx("span",{className:i,children:t})}function Ze(e){return e.candidateCount!==void 0&&e.candidateCount>1?`${e.candidateCount} candidates`:"unresolved"}function Xe(e){const t={resolved:0,unresolved:0,candidates:0};if(e===null)return t;for(const s of e.nodes)s.key!==e.focus.key&&(s.candidateCount!==void 0&&s.candidateCount>1?t.candidates+=1:s.unresolved?t.unresolved+=1:t.resolved+=1);return t}function Je(e){if(e===null)return[];const t=new Map;for(const i of e.nodes)t.set(i.key,i);const s=new Map;for(const i of e.edges){if(i.from!==e.focus.key)continue;const l=t.get(i.to);if(l===void 0)continue;const u=s.get(l.type)??[];u.push({node:l,relation:i.relation}),s.set(l.type,u)}const r=[];for(const i of Ge){const l=s.get(i);l&&l.length>0&&(l.sort((u,a)=>Number(u.node.unresolved)-Number(a.node.unresolved)),r.push({type:i,rows:l}))}return r}function Qe(e,t){if(e===null)return!1;const s=Date.parse(e);return Number.isFinite(s)?t-s>De:!1}function lt({open:e,onClose:t,beadId:s,initialBead:r=null,onOpenBead:i,depNode:l=null,sessions:u,renderActions:a}){const{bead:o,loading:c,error:f,notFound:m,now:d}=we(e,s,r),g=Be(e?s:null),[z,w]=h.useState(!1),S=o&&u&&o.assignee&&o.assignee.length>0?te(o.assignee,u):null,I=P(S),R=o?a?.(o):void 0,G=R||I?n.jsxs(n.Fragment,{children:[R,I&&n.jsx(J,{size:"sm",tone:"quiet",onClick:()=>w(!0),children:"View live run"})]}):void 0;return n.jsxs(n.Fragment,{children:[n.jsx(U,{open:e,onClose:t,title:o?.title??s??"Bead",caption:o?n.jsxs("span",{children:[n.jsx("code",{className:"text-fg-muted",children:o.id})," · ",o.issue_type," · P",o.priority==null?"—":o.priority]}):s?n.jsx("code",{className:"text-fg-muted",children:s}):void 0,widthClass:"max-w-3xl",footer:G,children:m?n.jsxs("div",{className:"space-y-2",children:[n.jsx("p",{className:"text-fg-muted",children:"This decision was resolved or removed."}),n.jsx("p",{className:"text-fg-faint text-sm",children:"The bead it pointed to is no longer in the supervisor — it was likely closed or pruned since this link was surfaced."})]}):f?n.jsx("p",{className:"text-accent",role:"alert",children:f}):c&&o===null?n.jsx("p",{className:"text-fg-muted italic",children:"Fetching bead."}):o===null?n.jsx("p",{className:"text-fg-muted italic",children:"No bead."}):n.jsxs("div",{className:"space-y-8",children:[n.jsx(Ce,{bead:o}),l&&n.jsx(Oe,{node:l,...i!==void 0?{onOpenBead:i}:{}}),n.jsx(Ke,{view:g.view,loading:g.loading,error:g.error,now:d,...i!==void 0?{onOpenBead:i}:{}})]})}),o&&n.jsx(Pe,{open:z,onClose:()=>w(!1),session:S,beadTitle:o.title})]})}export{lt as B,Ke as R,Be as u}; diff --git a/internal/api/dashboardspa/dist/assets/BeadDetailModal-BtVrX_Fu.js b/internal/api/dashboardspa/dist/assets/BeadDetailModal-BtVrX_Fu.js deleted file mode 100644 index cbed6162b6..0000000000 --- a/internal/api/dashboardspa/dist/assets/BeadDetailModal-BtVrX_Fu.js +++ /dev/null @@ -1 +0,0 @@ -import{r as h,u as H,a0 as K,a1 as O,J as V,I as E,a2 as q,z as W,j as n,S as Y,a3 as Z,L as J,B as X}from"./index-C20tCZFz.js";import{f as Q}from"./format-fte2CeYD.js";import{F as x}from"./Field-Dsl4x4KL.js";import{a as P,L as ee}from"./LiveSessionPeek-jm19JJ4Z.js";import{M as U}from"./constants-DBKWGg29.js";import{f as D}from"./time-D9v0saHV.js";function te(e,t){if(e.length===0||t.length===0)return null;const s=t.filter(r=>r.state==="active");return F(e,s)??F(e,t)}function F(e,t){for(const s of t)if(se(s,e))return s;return null}function se(e,t){return e.alias===t||e.pool===t||e.alias!==void 0&&A(e.alias,["/","."])===t||e.session_name!==void 0&&A(e.session_name,["__","--"])===t}function A(e,t){let s=-1,r=0;for(const i of t){const l=e.lastIndexOf(i);l>s&&(s=l,r=i.length)}return s<0?e:e.slice(s+r)}const ne=/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;function re(e,t,s){return`${e}:${s}:${t}`}function b(e,t,s){e?.(t,s)}const ie=/^pr\/(\d{1,9})$/,le=/^issue\/(\d{1,9})$/;function oe(e){const t=e.trim();if(t.length===0)return{ok:!1,error:"empty ref"};const s=ie.exec(t);if(s?.[1])return{ok:!0,type:"github_pr",value:s[1]};const r=le.exec(t);return r?.[1]?{ok:!0,type:"github_issue",value:r[1]}:ne.test(t)?{ok:!0,type:"bead",value:t}:{ok:!1,error:"unrecognised ref"}}function M(e){if(typeof e!="string")return null;const t=e.trim();return/^https?:\/\//i.test(t)?t:null}function v(e,t,s){return re(e,t,s)}function ae(e){return{key:v("bead",e.id,e.scope),type:"bead",ref:e.id}}function _(e,t){const s=e.stats.get(t);if(s)return s;const r={relation:t,resolved:0,unresolved:0,nCandidates:0};return e.stats.set(t,r),r}function y(e,t){e.nodesByKey.has(t.key)||(e.nodesByKey.set(t.key,t),e.view.nodes.push(t))}function k(e,t,s,r,i,l){e.view.edges.push({from:t,to:s,relation:r,provenance:i,resolved:l})}function N(e,t,s,r,i){const l=ae(s);y(e,{...l,title:s.title,status:s.status,url:null,fetchedAt:i,unresolved:!1}),k(e,t,l.key,r,"supervisor",!0),_(e,r).resolved+=1,b(e.recorder,r,"resolved")}function ue(e,t){return{focus:e,nodes:[],edges:[],stats:[],partial:!1,generatedAt:t,asOf:null}}function $(e,t){return e===null?t:t===null||Date.parse(e)<=Date.parse(t)?e:t}function ce(e,t,s={}){const i=(s.now??(()=>new Date))().toISOString(),l=s.supervisorFetchedAt??null,u=s.githubFetchedAt??null,a=de(e,t),o=ue(a.focus,i);o.partial=s.partial??!1;const c={view:o,nodesByKey:new Map,stats:new Map,recorder:s.recorder??(()=>{})};if(y(c,a.focusNode),!a.focusResolved)return o.partial=!0,L(c,l,u),o;const f=a.focusNode.key;if(a.beadFocus)for(const m of a.beads)pe(c,m,f,e,l,u);else for(const m of a.beads)N(c,f,m,"bead",l);return L(c,l,u),o}function de(e,t){if(t.type==="github_pr"||t.type==="github_issue"){const u=t.type==="github_pr"?"github_pr":"github_issue",a=t.type==="github_pr"?`pr/${t.value}`:`issue/${t.value}`,c=(t.type==="github_pr"?e.beadsForPr.get(t.value)??[]:e.beadsForIssue.get(t.value)??[]).map(m=>e.beads.get(m)).filter(m=>m!==void 0),f={key:v(u,t.value,"github"),type:u,ref:a};return{focus:f,focusNode:{...f,title:null,status:null,url:null,fetchedAt:null,unresolved:c.length===0,...c.length>1?{candidateCount:c.length}:{}},beads:c,focusResolved:c.length>0,beadFocus:!1}}const s=e.beads.get(t.value)??e.allBeads.get(t.value);if(s!==void 0){const u=fe(s);return{focus:u,focusNode:{...u,title:s.title,status:s.status,url:null,fetchedAt:null,unresolved:!1},beads:s.superseded?[]:[s],focusResolved:!0,beadFocus:!0}}const r=e.beadsForSession.get(t.value)??[],i=e.sessions.has(t.value);if(r.length>0||i){const u={key:v("session",t.value,"session"),type:"session",ref:t.value},a=r.map(c=>e.beads.get(c)).filter(c=>c!==void 0),o=i||a.length>0;return{focus:u,focusNode:{...u,title:e.sessions.get(t.value)?.title??null,status:e.sessions.get(t.value)?.state??null,url:null,fetchedAt:null,unresolved:!o},beads:a,focusResolved:o,beadFocus:!1}}const l={key:v("bead",t.value,"unknown"),type:"bead",ref:t.value};return{focus:l,focusNode:{...l,title:null,status:null,url:null,fetchedAt:null,unresolved:!0},beads:[],focusResolved:!1,beadFocus:!0}}function fe(e){return{key:v("bead",e.id,e.scope),type:"bead",ref:e.id}}function pe(e,t,s,r,i,l){if(t.parentBeadId){const a=r.beads.get(t.parentBeadId);a?N(e,s,a,"parent",i):me(e,s,t.parentBeadId,"parent")}const u=(r.childrenOf.get(t.id)??[]).filter(a=>a!==t.id);for(const a of u){const o=r.beads.get(a);o&&N(e,s,o,"child",i)}if(t.moleculeId){const a=(r.membersOfMolecule.get(t.moleculeId)??[]).filter(o=>o!==t.id&&o!==t.moleculeId);if(t.moleculeId!==t.id){const o=r.beads.get(t.moleculeId);o&&N(e,s,o,"molecule",i)}for(const o of a){const c=r.beads.get(o);c&&N(e,s,c,"molecule",i)}}if(t.prNumber&&B(e,s,"github_pr",`pr/${t.prNumber}`,t.prNumber,M(t.prUrl),"pr","supervisor",l),t.issueNumber&&B(e,s,"github_issue",`issue/${t.issueNumber}`,t.issueNumber,M(t.issueUrl),"issue","supervisor",l),t.sessionId){const a=r.sessions.get(t.sessionId),o={key:v("session",t.sessionId,"session"),type:"session",ref:t.sessionId};a?(y(e,{...o,title:a.title??a.alias??t.sessionName??null,status:a.state??null,url:null,fetchedAt:i,unresolved:!1}),k(e,s,o.key,"session","supervisor",!0),_(e,"session").resolved+=1,b(e.recorder,"session","resolved")):(y(e,{...o,title:t.sessionName??null,status:null,url:null,fetchedAt:i,unresolved:!0}),k(e,s,o.key,"session","supervisor",!1),_(e,"session").unresolved+=1,b(e.recorder,"session","unresolved"))}}function B(e,t,s,r,i,l,u,a,o){const c=v(s,i,"github");y(e,{key:c,type:s,ref:r,title:null,status:null,url:l,fetchedAt:o,unresolved:!0}),k(e,t,c,u,a,!1),_(e,u).unresolved+=1,b(e.recorder,u,"unresolved")}function me(e,t,s,r){const i=v("bead",s,"unknown");y(e,{key:i,type:"bead",ref:s,title:null,status:null,url:null,fetchedAt:null,unresolved:!0}),k(e,t,i,r,"supervisor",!1),_(e,r).unresolved+=1,b(e.recorder,r,"unresolved")}function L(e,t,s){e.view.stats=[...e.stats.values()].sort((i,l)=>i.relation.localeCompare(l.relation));let r=null;for(const i of e.view.nodes)r=$(r,i.fetchedAt);e.view.asOf=r??$(t,s)}function p(e,t){const s=e.metadata?.[t];if(typeof s=="string"){const r=s.trim();return r.length>0?r:void 0}if(typeof s=="number"&&Number.isFinite(s))return String(s)}function he(e,t){const s=e.metadata?.[t];if(typeof s=="number"&&Number.isInteger(s)&&s>=0)return s;if(typeof s=="string"&&/^\d+$/.test(s.trim()))return Number.parseInt(s.trim(),10)}const ge=["gc.scope_ref","scope_ref","scope_id"],xe=["gc.scope_kind","scope_kind"];function ve(e,t){let s;for(const i of ge){const l=p(e,i);if(l!==void 0){s=l;break}}let r;for(const i of xe){const l=p(e,i);if(l!==void 0){r=l;break}}return s===void 0?`city:${t}`:`${r??"rig"}:${s}`}const ye=/^github-pr:[^/]+\/[^/]+\/(\d+)$/,je=/\/(?:pull\/)?(\d+)(?:[/?#]|$)/;function Ne(e){const t=p(e,"evidence.pr_url"),s=p(e,"evidence.pr_number"),r=p(e,"evidence.artifact_path"),i=p(e,"pr_review.pr_number"),l=p(e,"pr_review.pr_url"),u=r?.match(ye),a=t?.match(je),o=s??u?.[1]??a?.[1]??i??void 0,c=t??l??void 0,f={};return o!==void 0&&(f.prNumber=o),c!==void 0&&(f.prUrl=c),f}function be(e,t){const{prNumber:s,prUrl:r}=Ne(e),i={id:e.id,title:e.title,status:e.status,scope:ve(e,t),superseded:!1},l={parentBeadId:p(e,"gc.parent_bead_id"),rootBeadId:p(e,"gc.root_bead_id"),moleculeId:p(e,"molecule_id"),prNumber:s,prUrl:r,issueNumber:p(e,"bugflow.github_issue_number")??p(e,"design_review.github_issue_number"),issueUrl:p(e,"bugflow.github_issue_url")??p(e,"design_review.github_issue_url"),sessionId:p(e,"session_id"),sessionName:p(e,"session_name"),stepId:p(e,"gc.step_id"),attempt:he(e,"gc.attempt")};for(const[u,a]of Object.entries(l))a!==void 0&&Object.assign(i,{[u]:a});return i}function T(e){return`${e.moleculeId}\0${e.stepId}`}function _e(e){const t=new Map;for(const s of e){if(s.moleculeId===void 0||s.stepId===void 0||s.attempt===void 0)continue;const r=T(s),i=t.get(r);(i===void 0||s.attempt>i)&&t.set(r,s.attempt)}for(const s of e){if(s.moleculeId===void 0||s.stepId===void 0||s.attempt===void 0)continue;const r=t.get(T(s));r!==void 0&&s.attemptbe(d,s));_e(r);const i=new Map,l=new Map,u=new Map,a=new Map,o=new Map,c=new Map,f=new Map;for(const d of r)i.set(d.id,d),!d.superseded&&(l.set(d.id,d),d.parentBeadId&&j(u,d.parentBeadId,d.id),d.moleculeId&&j(a,d.moleculeId,d.id),d.prNumber&&j(o,d.prNumber,d.id),d.issueNumber&&j(c,d.issueNumber,d.id),d.sessionId&&j(f,d.sessionId,d.id));const m=new Map;for(const d of t)m.set(d.id,d);return{beads:l,allBeads:i,childrenOf:u,membersOfMolecule:a,beadsForPr:o,beadsForIssue:c,beadsForSession:f,sessions:m}}function we(e,t,s=null){const[r,i]=h.useState(s),[l,u]=h.useState(!1),[a,o]=h.useState(null),[c,f]=h.useState(!1),m=H();return h.useEffect(()=>{if(!e||!t)return;if(s&&s.id===t&&s.description!==void 0){i(s),o(null),f(!1);return}i(s?.id===t?s:null),u(!0),o(null),f(!1);let d=!1;return(async()=>{try{const g=await K(t);d||i(g)}catch(g){if(d)return;g instanceof O&&g.status===404?f(!0):o(Se(g))}finally{d||u(!1)}})(),()=>{d=!0}},[e,t,s]),{bead:r,loading:l,error:a,notFound:c,now:m}}function Se(e){return e instanceof O?e.status===void 0?e.message:`${e.status} ${e.message}`:e instanceof Error?e.message:"fetch failed"}function Ie(e){return e.partial===!0||(e.partial_errors?.length??0)>0||(e.next_cursor?.length??0)>0}function Re(e,t){return Ie(e)||typeof e.total=="number"&&e.total>t}const Ee=1e3;async function Fe(e){const t=oe(e);if(!t.ok)throw new Error(t.error);const s=V("load supervisor entity links"),r=new Date().toISOString(),i=await E().listBeads(s,{limit:Ee}),l=Ae(i.items??[]);let u=Re(i,l.length),a=[];try{const c=await E().listSessions(s);a=q(c),u||=$e(c)}catch{u=!0}const o=ke(l,a,s);return ce(o,t,{partial:u,supervisorFetchedAt:r,githubFetchedAt:null})}function Ae(e){return e.map(Me)}function Me(e){const t={id:e.id,title:e.title,status:e.status,issue_type:e.issue_type,priority:e.priority??null,created_at:e.created_at};return e.description!==void 0&&(t.description=e.description),e.assignee!==void 0&&(t.assignee=e.assignee),Array.isArray(e.labels)&&(t.labels=e.labels),e.metadata!==void 0&&(t.metadata=e.metadata),e.ref!==void 0&&(t.ref=e.ref),e.parent!==void 0&&(t.parent=e.parent),e.from!==void 0&&(t.from=e.from),e.ephemeral!==void 0&&(t.ephemeral=e.ephemeral),e.needs!==void 0&&(t.needs=e.needs),e.dependencies!==void 0&&(t.dependencies=e.dependencies),e.updated_at!==void 0&&(t.updated_at=e.updated_at),t}function $e(e){return e.partial===!0||(e.partial_errors?.length??0)>0}function Be(e){const[t,s]=h.useState(null),[r,i]=h.useState(!1),[l,u]=h.useState(null);return h.useEffect(()=>{if(e===null||e.length===0){s(null),u(null),i(!1);return}let a=!1;return i(!0),u(null),(async()=>{try{const o=await Fe(e);a||s(o)}catch(o){if(a)return;u(W(o,"related entities failed")),s(null)}finally{a||i(!1)}})(),()=>{a=!0}},[e]),{view:t,loading:r,error:l}}function Le(e){const t=e.metadata;if(!t)return{};const s={};return t["gc.kind"]&&(s.kind=t["gc.kind"]),t["gc.source_bead_id"]&&(s.originBeadId=t["gc.source_bead_id"]),t["gc.formula_contract"]&&(s.formulaContract=t["gc.formula_contract"]),t["gc.run_target"]?s.runTarget=t["gc.run_target"]:t["gc.routed_to"]&&(s.runTarget=t["gc.routed_to"]),s}function Te(e,t){return t.kind==="run"?"template":e.issue_type==="molecule"?"wisp":"work"}function Ce({bead:e}){const t=Le(e),s=Te(e,t);return n.jsxs("div",{className:"space-y-8",children:[s==="template"&&n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:"Formula template"}),n.jsxs("p",{className:"text-body text-fg-muted max-w-prose",children:["This bead is a recipe, not actionable work. Every"," ",e.ref?n.jsx("code",{className:"text-fg-muted",children:e.ref}):"wisp"," instance is instantiated from this template. The ",n.jsx("span",{className:"text-fg-muted",children:"in_progress"})," ","status is the gc-system convention for ",'"',"available for instantiation",'"'," — do not act on it, nudge it, or close it."]})]}),s==="wisp"&&n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:"Formula instance"}),n.jsxs("p",{className:"text-body text-fg-muted max-w-prose",children:["One run of the"," ",e.title?n.jsx("code",{className:"text-fg-muted",children:e.title}):"formula"," recipe."]})]}),n.jsxs("dl",{className:"grid grid-cols-2 sm:grid-cols-4 gap-x-8 gap-y-5",children:[n.jsx(x,{label:"Status",children:n.jsx(Y,{tone:Z(e.status),label:e.status})}),n.jsx(x,{label:"Type",children:e.issue_type}),n.jsx(x,{label:"Assignee",children:e.assignee||"·"}),n.jsx(x,{label:"Created",children:n.jsx("span",{className:"tnum",children:Q(e.created_at)})})]}),s==="template"&&(t.formulaContract||t.originBeadId||t.runTarget)&&n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:"Template origin"}),n.jsx("p",{className:"text-body text-fg-muted max-w-prose mb-4",children:"Where this formula came from, kept for traceability. The origin bead and target may be stale; the formula itself is now used wherever the pool dispatches it."}),n.jsxs("dl",{className:"grid grid-cols-2 sm:grid-cols-3 gap-x-8 gap-y-3",children:[t.formulaContract&&n.jsx(x,{label:"Contract",children:n.jsx("code",{className:"text-fg-muted",children:t.formulaContract})}),e.ref&&n.jsx(x,{label:"Ref",children:n.jsx("code",{className:"text-fg-muted",children:e.ref})}),t.originBeadId&&n.jsx(x,{label:"Origin bead",children:n.jsx("code",{className:"text-fg-muted",children:t.originBeadId})}),t.runTarget&&n.jsx(x,{label:"Origin target",children:n.jsx("span",{className:"text-fg-muted truncate",title:t.runTarget,children:t.runTarget})})]})]}),Array.isArray(e.labels)&&e.labels.length>0&&n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:"Labels"}),n.jsx("div",{className:"flex flex-wrap gap-x-3 gap-y-1",children:e.labels.map(r=>n.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:r},r))})]}),n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:s==="template"?"Recipe":"Description"}),e.description&&e.description.length>0?n.jsx("pre",{className:"text-body whitespace-pre-wrap leading-relaxed text-fg font-sans",children:e.description}):n.jsx("p",{className:"text-body text-fg-muted italic",children:"No description."})]})]})}function Oe({node:e,onOpenBead:t}){const{deps:s,blocks:r}=e,i=s.length>0||r.length>0;return n.jsxs("section",{children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint mb-3",children:"Dependencies"}),i?n.jsxs("div",{className:"space-y-6",children:[s.length>0&&n.jsxs("div",{children:[n.jsxs("p",{className:"text-label uppercase tracking-wider text-fg-muted mb-2",children:["Needs ",n.jsx("span",{className:"tnum",children:s.length})]}),n.jsx("ul",{className:"space-y-1",children:s.map(l=>n.jsx(C,{relation:l.kind==="needs"?null:l.kind,targetId:l.id,targetTitle:l.bead?.title??null,...l.bead&&t?{onOpenBead:t}:{}},`needs-${l.id}`))})]}),r.length>0&&n.jsxs("div",{children:[n.jsxs("p",{className:"text-label uppercase tracking-wider text-fg-muted mb-2",children:["Blocks ",n.jsx("span",{className:"tnum",children:r.length})]}),n.jsx("ul",{className:"space-y-1",children:r.map(l=>n.jsx(C,{relation:null,targetId:l.id,targetTitle:l.title,...t?{onOpenBead:t}:{}},`blocks-${l.id}`))})]})]}):n.jsx("p",{className:"text-body text-fg-muted italic",children:"No dependencies."})]})}function C({relation:e,targetId:t,targetTitle:s,onOpenBead:r}){const i=n.jsxs(n.Fragment,{children:[e&&n.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-faint",children:[e," "]}),n.jsx("span",{className:"tnum text-fg-muted",children:t}),s&&n.jsxs("span",{className:"text-fg",children:[" · ",s]})]});return n.jsx("li",{className:"text-body leading-snug",children:r?n.jsx("button",{type:"button",onClick:()=>r(t),className:"text-left text-fg-muted hover:text-fg focus-mark rounded-sm",title:`Open ${t}`,children:i}):n.jsxs("span",{title:"Outside the fetched window",children:[i," ",n.jsx("span",{className:"text-warn text-label uppercase tracking-wider",children:"unresolved"})]})})}function Pe({open:e,onClose:t,session:s,beadTitle:r}){const i=P(s);return n.jsx(U,{open:e,onClose:t,title:r,caption:s===null?"No live session resolved for this bead.":i?"Live transcript from the supervisor's session stream.":"Snapshot from the supervisor's transcript API.",widthClass:"max-w-5xl",children:n.jsx(ee,{sessionId:s?.id??null,stream:i,showBadge:!0,showCaption:!0})})}const Ue=6,De=3600*1e3,ze=3,Ge=["bead","formula_run","session","github_pr","github_issue","order_run"],He={bead:"Beads",session:"Sessions",github_pr:"Pull requests",github_issue:"Issues",formula_run:"Formula runs",order_run:"Order runs"};function Ke({view:e,loading:t,error:s,now:r,onOpenBead:i}){const[l,u]=h.useState(!1),a=h.useMemo(()=>Xe(e),[e]),o=h.useMemo(()=>Je(e),[e]),c=o.unresolved>=ze;return n.jsxs("section",{className:"mt-12",children:[n.jsxs("header",{className:"flex items-baseline justify-between mb-4 gap-3",children:[n.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Related"}),n.jsxs("div",{className:"flex items-baseline gap-3 min-w-0",children:[e&&e.asOf&&n.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum shrink-0",children:["as of ",D(e.asOf,r)]}),n.jsx(Ve,{loading:t,counts:o,showMark:c})]})]}),s!==null?n.jsx("p",{className:"text-body text-accent",role:"alert",children:s}):t&&e===null?n.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading related entities."}):e===null||a.length===0?n.jsx("p",{className:"text-body text-fg-muted italic",children:"No related entities."}):n.jsxs(n.Fragment,{children:[e.partial&&n.jsx("p",{className:"text-label uppercase tracking-wider text-warn mb-4",role:"status",children:"Partial: some sources did not load. Links may be incomplete."}),n.jsx("button",{type:"button",onClick:()=>u(f=>!f),className:"text-label uppercase tracking-wider text-fg-faint hover:text-fg focus-mark mb-4","aria-expanded":l,children:l?"Hide detail":"Show detail"}),l&&n.jsx("div",{className:"space-y-8",children:a.map(f=>n.jsx(qe,{type:f.type,rows:f.rows,now:r,...i!==void 0?{onOpenBead:i}:{}},f.type))})]})]})}function Ve({loading:e,counts:t,showMark:s}){if(e)return n.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint",children:"·"});const r=[];t.resolved>0&&r.push(`${t.resolved} resolved`),t.unresolved>0&&r.push(`${t.unresolved} unresolved`),t.candidates>0&&r.push(`${t.candidates} candidates`);const i=r.length>0?r.join(", "):"none";return n.jsxs("span",{className:`text-label uppercase tracking-wider tnum truncate ${s?"text-accent":"text-fg-faint"}`,children:[s&&n.jsx("span",{"aria-hidden":!0,children:"■ "}),i]})}function qe({type:e,rows:t,now:s,onOpenBead:r}){const i=t.slice(0,Ue),l=t.length-i.length;return n.jsxs("div",{children:[n.jsxs("header",{className:"flex items-baseline justify-between mb-2",children:[n.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint",children:He[e]}),n.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:t.length})]}),n.jsx("ul",{className:"space-y-2",children:i.map(u=>n.jsx(We,{row:u,now:s,...r!==void 0?{onOpenBead:r}:{}},`${u.relation}\0${u.node.key}`))}),l>0&&n.jsxs("p",{className:"text-label uppercase tracking-wider text-fg-faint mt-2",children:["+ ",l," more"]})]})}function We({row:e,now:t,onOpenBead:s}){const{node:r,relation:i}=e,l=Qe(r.fetchedAt,t),u=r.title??r.ref,a=r.unresolved||l;return n.jsxs("li",{className:"flex items-baseline gap-3 min-w-0",children:[n.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint shrink-0 w-20 truncate",children:i}),n.jsx("span",{className:"min-w-0 flex-1 truncate",children:n.jsx(Ye,{node:r,label:u,dimmed:a,...s!==void 0?{onOpenBead:s}:{}})}),n.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum shrink-0",children:r.unresolved?Ze(r):r.fetchedAt?D(r.fetchedAt,t):r.status??"·"})]})}function Ye({node:e,label:t,dimmed:s,onOpenBead:r}){const i=`text-body text-left truncate min-w-0 focus-mark ${s?"text-fg-muted":"text-fg hover:text-accent"}`;return e.type==="bead"&&!e.unresolved&&r?n.jsx("button",{type:"button",onClick:()=>r(e.ref),className:i,title:`Open ${e.ref}`,children:t}):e.type==="session"&&!e.unresolved?n.jsx(J,{to:`/agents/${encodeURIComponent(e.ref)}`,className:i,children:t}):e.url?n.jsxs("a",{href:e.url,target:"_blank",rel:"noreferrer noopener",className:i,title:e.url,children:[t," ",n.jsx("span",{"aria-hidden":!0,children:"↗"})]}):n.jsx("span",{className:i,children:t})}function Ze(e){return e.candidateCount!==void 0&&e.candidateCount>1?`${e.candidateCount} candidates`:"unresolved"}function Je(e){const t={resolved:0,unresolved:0,candidates:0};if(e===null)return t;for(const s of e.nodes)s.key!==e.focus.key&&(s.candidateCount!==void 0&&s.candidateCount>1?t.candidates+=1:s.unresolved?t.unresolved+=1:t.resolved+=1);return t}function Xe(e){if(e===null)return[];const t=new Map;for(const i of e.nodes)t.set(i.key,i);const s=new Map;for(const i of e.edges){if(i.from!==e.focus.key)continue;const l=t.get(i.to);if(l===void 0)continue;const u=s.get(l.type)??[];u.push({node:l,relation:i.relation}),s.set(l.type,u)}const r=[];for(const i of Ge){const l=s.get(i);l&&l.length>0&&(l.sort((u,a)=>Number(u.node.unresolved)-Number(a.node.unresolved)),r.push({type:i,rows:l}))}return r}function Qe(e,t){if(e===null)return!1;const s=Date.parse(e);return Number.isFinite(s)?t-s>De:!1}function lt({open:e,onClose:t,beadId:s,initialBead:r=null,onOpenBead:i,depNode:l=null,sessions:u,renderActions:a}){const{bead:o,loading:c,error:f,notFound:m,now:d}=we(e,s,r),g=Be(e?s:null),[z,w]=h.useState(!1),S=o&&u&&o.assignee&&o.assignee.length>0?te(o.assignee,u):null,I=P(S),R=o?a?.(o):void 0,G=R||I?n.jsxs(n.Fragment,{children:[R,I&&n.jsx(X,{size:"sm",tone:"quiet",onClick:()=>w(!0),children:"View live run"})]}):void 0;return n.jsxs(n.Fragment,{children:[n.jsx(U,{open:e,onClose:t,title:o?.title??s??"Bead",caption:o?n.jsxs("span",{children:[n.jsx("code",{className:"text-fg-muted",children:o.id})," · ",o.issue_type," · P",o.priority==null?"—":o.priority]}):s?n.jsx("code",{className:"text-fg-muted",children:s}):void 0,widthClass:"max-w-3xl",footer:G,children:m?n.jsxs("div",{className:"space-y-2",children:[n.jsx("p",{className:"text-fg-muted",children:"This decision was resolved or removed."}),n.jsx("p",{className:"text-fg-faint text-sm",children:"The bead it pointed to is no longer in the supervisor — it was likely closed or pruned since this link was surfaced."})]}):f?n.jsx("p",{className:"text-accent",role:"alert",children:f}):c&&o===null?n.jsx("p",{className:"text-fg-muted italic",children:"Fetching bead."}):o===null?n.jsx("p",{className:"text-fg-muted italic",children:"No bead."}):n.jsxs("div",{className:"space-y-8",children:[n.jsx(Ce,{bead:o}),l&&n.jsx(Oe,{node:l,...i!==void 0?{onOpenBead:i}:{}}),n.jsx(Ke,{view:g.view,loading:g.loading,error:g.error,now:d,...i!==void 0?{onOpenBead:i}:{}})]})}),o&&n.jsx(Pe,{open:z,onClose:()=>w(!1),session:S,beadTitle:o.title})]})}export{lt as B,Ke as R,Be as u}; diff --git a/internal/api/dashboardspa/dist/assets/Beads-BlpKAjIK.js b/internal/api/dashboardspa/dist/assets/Beads-BlpKAjIK.js new file mode 100644 index 0000000000..43d9c2c1f6 --- /dev/null +++ b/internal/api/dashboardspa/dist/assets/Beads-BlpKAjIK.js @@ -0,0 +1 @@ +import{j as e,S as fe,B as C,r as o,H as U,I as te,a as $e,g as Oe,J as Pe,b as G,c as Le,l as Fe,f as Te,z as me,R as pe,i as K,F as De,G as qe}from"./index-CJ6RRl2D.js";import{b as ze,r as He}from"./routeHighlight-B30gQO2o.js";import{B as Ve}from"./BeadDetailModal-3OfdZfdZ.js";import{u as Ge,F as Ke}from"./useListFilters-DQXiYJvO.js";import{L as Ue,f as Ye}from"./projectOf-B27nlS9X.js";import{M as ge}from"./constants-BJUwiA6r.js";import{P as Je}from"./PageHeader-4s_Bnmfl.js";import{l as Xe}from"./agentReads-D6V_h6J8.js";import"./format-fte2CeYD.js";import"./Field-9mEFYSnz.js";import"./LiveSessionPeek-D86UU9cf.js";import"./time-D9v0saHV.js";function Qe(t){if(t===void 0)return null;const n=t.indexOf("?");if(n<0)return null;const l=new URLSearchParams(t.slice(n+1)).get("bead");return l!==null&&l.length>0?l:null}function We({items:t,onOpen:n}){const l=t.filter(a=>a.severity==="attention"||a.severity==="watch");return l.length===0?null:e.jsxs("section",{"aria-labelledby":"beads-attention-title",className:"mb-8 space-y-3",children:[e.jsxs("h2",{id:"beads-attention-title",className:"text-label uppercase tracking-wider text-fg-muted",children:["Needs you ",e.jsxs("span",{className:"tnum text-fg",children:["(",l.length,")"]})]}),e.jsx("ul",{className:"space-y-2",children:l.map(a=>{const i=Qe(a.href);return e.jsxs("li",{className:"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1",children:[e.jsxs("div",{className:"min-w-0 space-y-0.5",children:[e.jsx(fe,{tone:a.severity==="attention"?"stuck":"warn",label:a.title}),a.summary!==void 0&&e.jsx("p",{className:"text-body text-fg-muted",children:a.summary})]}),i!==null&&e.jsx("div",{className:"flex items-center gap-2",children:e.jsx(C,{type:"button",size:"sm",tone:"quiet",onClick:()=>n(i),children:"Open"})})]},a.id)})})]})}const se=[{id:"ready",label:"ready"},{id:"open",label:"open"},{id:"in_progress",label:"in progress"},{id:"blocked",label:"blocked"},{id:"done",label:"done"}];function Ze(t){const n=new Set,l=[];for(const a of t.needs??[])a.length===0||n.has(a)||(n.add(a),l.push({id:a,kind:"needs"}));for(const a of t.dependencies??[]){const i=a.depends_on_id;i.length===0||n.has(i)||(n.add(i),l.push({id:i,kind:a.type}))}return l}function et(t){return(t.needs??[]).filter(n=>n.length>0)}function tt(t){switch(t.bead.status){case"in_progress":return"in_progress";case"blocked":return"blocked";case"closed":return"done";default:return t.ready?"ready":"open"}}function st(t,n){const l=t.bead.priority??Number.POSITIVE_INFINITY,a=n.bead.priority??Number.POSITIVE_INFINITY;return l!==a?l-a:t.bead.idn.bead.id?1:0}function nt(t){const n=new Map;for(const r of t)n.set(r.id,r);const l=new Map,a=new Map;for(const r of t){const c=Ze(r).map(({id:m,kind:g})=>({id:m,kind:g,bead:n.get(m)??null})),u=c.some(m=>m.bead===null),d=et(r),h=r.status==="open"&&d.every(m=>n.get(m)?.status==="closed"),p={bead:r,deps:c,blocks:[],ready:h,hasUnresolvedDeps:u,column:"open"};p.column=tt(p),a.set(r.id,p);for(const m of c){if(m.bead===null)continue;const g=l.get(m.id);g?g.push(r):l.set(m.id,[r])}}for(const[r,c]of l){const u=a.get(r);u&&(u.blocks=[...c].sort((d,h)=>d.idh.id?1:0))}const i=be();for(const r of a.values())i[r.column].push(r);for(const r of se)i[r.id].sort(st);return{nodes:a,columns:i}}function be(){return{ready:[],open:[],in_progress:[],blocked:[],done:[]}}function at(t,n){const l=be();for(const a of se)l[a.id]=t.columns[a.id].filter(i=>n.has(i.bead.id));return l}function lt({node:t,selected:n,attentionSeverity:l=null,onSelect:a}){const{bead:i,deps:r,blocks:c,hasUnresolvedDeps:u}=t,d=o.useRef(null),h=r.length,p=c.length,m=h>0||p>0,{className:g="",...S}=ze(l);return o.useEffect(()=>{n&&d.current?.scrollIntoView?.({block:"center",inline:"nearest"})},[n]),e.jsx("li",{ref:d,...S,className:`px-2 py-2 -mx-2 rounded-sm transition-colors duration-150 ease-out-quart ${n?"bg-surface-tint":"hover:bg-surface-tint/60"} ${g}`,children:e.jsxs("button",{type:"button",onClick:()=>a(i.id),className:"text-left w-full focus-mark rounded-sm","aria-pressed":n,title:`Select ${i.id}`,children:[e.jsxs("span",{className:"flex items-baseline gap-2",children:[e.jsx("span",{className:"text-fg-faint","aria-hidden":"true",children:n?"▸":" "}),e.jsx("span",{className:`min-w-0 line-clamp-2 text-body ${n?"text-fg font-medium":"text-fg"}`,children:i.title})]}),e.jsxs("span",{className:"flex items-baseline gap-3 pl-4 mt-0.5 text-label uppercase tracking-wider text-fg-faint",children:[e.jsx("span",{className:"tnum",children:i.id}),i.priority!=null&&e.jsxs("span",{className:"tnum",children:["P",i.priority]}),m&&e.jsxs("span",{className:"tnum normal-case tracking-normal",children:[h>0&&`needs ${h}`,h>0&&p>0&&" · ",p>0&&`blocks ${p}`]}),u&&e.jsx("span",{className:"normal-case tracking-normal text-warn",children:"unresolved"})]})]})})}function rt({columns:t,selectedId:n,attentionSeverity:l,onSelect:a}){return e.jsx("div",{className:"grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-x-8 gap-y-8",children:se.map(i=>{const r=t[i.id],u=i.id==="blocked"&&r.length>0?"text-accent":"text-fg-muted";return e.jsxs("section",{"aria-label":i.label,children:[e.jsxs("header",{className:"flex items-baseline justify-between border-b border-rule pb-2 mb-3",children:[e.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-muted",children:i.label}),e.jsx("span",{className:`text-label tnum ${u}`,children:r.length})]}),r.length===0?e.jsx("p",{className:"text-body text-fg-faint italic",children:"·"}):e.jsx("ul",{className:"space-y-1",children:r.map(d=>e.jsx(lt,{node:d,selected:d.bead.id===n,attentionSeverity:l?.(d.bead.id)??null,onSelect:a},d.bead.id))})]},i.id)})})}function ot({label:t,count:n,graph:l,ids:a,selectedId:i,attentionSeverity:r,onSelect:c}){const u=at(l,a);return e.jsxs("section",{"aria-label":t,children:[e.jsxs("header",{className:"flex items-baseline justify-between border-b border-rule pb-2 mb-4",children:[e.jsx("h2",{className:"text-headline text-fg",children:t}),e.jsx("span",{className:"text-label tnum text-fg-muted",children:n})]}),e.jsx(rt,{columns:u,selectedId:i,...r===void 0?{}:{attentionSeverity:r},onSelect:c})]})}function it(t,n){const l=t?.trim();if(!l)return;const a=n.find(r=>r.name===l);return a?a.name:n.find(r=>r.path===l)?.name}function ct(t){return Array.from(new Set(t.map(n=>n.name.trim()).filter(n=>n.length>0))).sort((n,l)=>n.localeCompare(l))}async function dt(){const t=await U().listRigs(te("list supervisor rigs"));return{...t,items:t.items??[]}}async function ut(t){await U().closeBead(te("close supervisor bead"),t)}async function mt(t){const n=t.title.trim(),l=t.description.trim(),a=t.rig.trim(),i=t.target.trim();if(n.length===0)throw new Error("bead title is required");if(i.length===0)throw new Error("sling target is required");const r=te("create and sling supervisor bead"),c={title:n};l.length>0&&(c.description=l);const u=await U().createBead(r,c),d={bead:u.id,target:i};a.length>0&&(d.rig=a);const h=await U().sling(r,d);return{bead:u,sling:h}}const pt=new Set,N="",xe="closed",gt=1e4,he=[{id:"open",label:"open",match:t=>t.status==="open"},{id:"in_progress",label:"in progress",match:t=>t.status==="in_progress"},{id:"blocked",label:"blocked",match:t=>t.status==="blocked"},{id:xe,label:"closed",match:t=>t.status==="closed"}],ht=t=>[t.id,t.title,t.assignee,...t.labels??[]];function At(){const t=$e(),n=Oe(),a=De()??"no-city",[i]=Pe(),r=ft(i.get("bead")),[c,u]=o.useState(N),[d,h]=o.useState(!1),[p,m]=o.useState(r),[g,S]=o.useState(null),[I,ne]=o.useState(null),[O,B]=o.useState(null),[Y,P]=o.useState(!1),[L,ae]=o.useState(!1),[le,J]=o.useState(null),[F,re]=o.useState(""),[X,oe]=o.useState(""),[R,ie]=o.useState(""),[y,_]=o.useState(""),{data:v,loading:T,error:ce,refresh:A}=G(`beads:board:${a}:${c}:${d?"all":"open"}`,()=>Le({includeClosed:d,...c===N?{}:{rigFilter:c}})),ye=o.useMemo(()=>v?.items??[],[v]),de=v?.total??0,Q=v?.upstream_total,W=v?.upstream_fetched,je=v?.fetch_limit,D=v!==void 0,q=G(`sessions:${a}`,Fe),Ne=o.useMemo(()=>q.data?.items??[],[q.data]),E=G(`agents:${a}`,Xe),j=o.useMemo(()=>E.data?.items??[],[E.data]),z=G(`rigs:${a}`,dt),H=o.useMemo(()=>z.data?.items??[],[z.data]),w=o.useMemo(()=>ct(H),[H]),k=o.useCallback(s=>it(s.rig,H),[H]),M=o.useMemo(()=>R.length===0?j:j.filter(s=>k(s)===R),[j,k,R]);o.useEffect(()=>{if(Y){if(M.length===0){y.length>0&&_("");return}M.some(s=>s.name===y)||_(M[0]?.name??"")}},[Y,M,y]),o.useEffect(()=>{c!==N&&!w.includes(c)&&u(N)},[w,c]);const V=ye,f=Ge({viewKey:"beads",rows:V,projectOf:Ye,searchOf:ht,chips:he}),{toggleChip:ue}=f,we=o.useCallback(s=>{s===xe&&h(b=>!b),ue(s)},[ue]);Te([qe.bead],()=>{A()},{coalesceMs:gt}),o.useEffect(()=>{r!==null&&m(r)},[r]);const Ce=o.useCallback(async s=>{if(!n){ne(s.id),B(null);try{await ut(s.id),S(null),B({tone:"ok",text:`Closed ${s.id}.`}),await A()}catch(b){B({tone:"error",text:me(b,"close failed")})}finally{ne(null)}}},[n,A]),ve=o.useCallback(()=>{const s=w[0]??"",b=j.find(x=>s.length===0||k(x)===s);re(""),oe(""),ie(s),_(b?.name??""),J(null),B(null),P(!0)},[j,k,w]),ke=o.useCallback(s=>{if(ie(s),!j.some(x=>x.name===y&&(s.length===0||k(x)===s))){const x=j.find(ee=>s.length===0||k(ee)===s);_(x?.name??"")}},[j,k,y]),Se=o.useCallback(async()=>{if(!n){ae(!0),J(null);try{const s=await mt({title:F,description:X,rig:R,target:y});B({tone:"ok",text:`Created ${s.bead.id} and slung to ${y}.`}),P(!1),await A()}catch(s){J(me(s,"create and sling failed"))}finally{ae(!1)}}},[y,X,R,F,n,A]),$=o.useMemo(()=>f.groups.flatMap(s=>s.rows),[f.groups]),Z=o.useMemo(()=>nt($),[$]),Ie=o.useMemo(()=>{const s=new Map;for(const b of f.groups)s.set(b.projectKey,new Set(b.rows.map(x=>x.id)));return s},[f.groups]),Be=o.useMemo(()=>$.find(s=>s.id===p)??null,[$,p]),Re=o.useMemo(()=>p===null?null:Z.nodes.get(p)??null,[Z,p]),Ae=o.useMemo(()=>s=>He(t,"beads",s),[t]),_e=o.useCallback(s=>{const b=I!==null,x=I===s.id?"closing":null,ee=n?K:void 0;return e.jsxs("div",{className:"flex flex-wrap items-center justify-end gap-2",children:[n&&e.jsx(pe,{}),x&&e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint",children:x}),e.jsx(C,{type:"button",size:"sm",tone:"quiet",title:ee,disabled:n||b||s.status==="closed",onClick:()=>{B(null),S(s)},children:"Close"})]})},[I,n]),Ee=o.useMemo(()=>D?bt(V,de,c):"Loading beads.",[V,D,de,c]),Me=typeof Q=="number"&&typeof W=="number"&&W{A()},disabled:T,children:T&&!D?"Loading":T?"Refreshing":"Refresh"})]})}),e.jsxs("div",{className:"space-y-2 mb-6 text-body text-fg-muted max-w-prose",children:[Me&&e.jsx("p",{className:"text-warn",children:e.jsx(fe,{tone:"warn",label:`Fetch window covered ${W} of ${Q} store beads. Raise the fetch limit (currently ${je??"?"}) if engineering work sits past the window.`})}),c!==N&&e.jsxs("p",{children:["Filtering by rig ",e.jsx("span",{className:"text-accent",children:c}),"."," ",e.jsx("button",{type:"button",onClick:()=>u(N),className:"text-fg-muted hover:text-fg focus-mark underline decoration-dotted underline-offset-2 rounded-sm",children:"Clear"})]}),O&&e.jsx("p",{className:O.tone==="error"?"text-accent":"text-fg-muted",role:O.tone==="error"?"alert":"status",children:O.text})]}),e.jsx(We,{items:t.byDomain.beads.items,onOpen:m}),e.jsxs("div",{className:"mb-6 space-y-3",children:[e.jsx(Ue,{value:f.search,onChange:f.setSearch,placeholder:"Search beads by id, title, label, assignee",matchCount:f.totalMatches,totalCount:V.length,ariaLabel:"Search beads"}),e.jsxs("div",{className:"flex flex-wrap items-baseline gap-x-8 gap-y-3",children:[e.jsx(Ke,{chips:he,activeIds:f.activeChipIds,onToggle:we,legend:"Status"}),w.length>1&&e.jsxs("label",{className:"flex items-baseline gap-2 text-label",children:[e.jsx("span",{className:"uppercase tracking-wider text-fg-muted",children:"Rig"}),e.jsxs("select",{value:c,onChange:s=>u(s.target.value),"aria-label":"Rig filter",className:"text-label uppercase tracking-wider text-fg-muted bg-transparent border-0 focus-mark cursor-pointer hover:text-fg transition-colors duration-150 ease-out-quart",children:[e.jsx("option",{value:N,children:"all rigs"}),w.map(s=>e.jsx("option",{value:s,children:s},s))]})]})]})]}),!D&&T?e.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading beads."}):$.length===0?e.jsx("p",{className:"text-body text-fg-muted italic",children:f.search.length>0||f.activeChipIds.size>0?"No beads match the current search or filter.":"Nothing on the queue right now."}):e.jsx("div",{className:"space-y-12",children:f.groups.map(s=>e.jsx(ot,{label:s.project,count:s.totalInProject,graph:Z,ids:Ie.get(s.projectKey)??pt,selectedId:p,attentionSeverity:Ae,onSelect:m},s.projectKey))}),e.jsx(Ve,{open:p!==null,onClose:()=>m(null),beadId:p,initialBead:Be,depNode:Re,sessions:Ne,onOpenBead:m,renderActions:_e}),e.jsx(ge,{open:g!==null,onClose:()=>{I===null&&S(null)},title:g?`Close ${g.id}`:"Close bead",caption:g?.title,widthClass:"max-w-xl",footer:e.jsxs(e.Fragment,{children:[e.jsx(C,{type:"button",size:"sm",tone:"quiet",disabled:I!==null,onClick:()=>S(null),children:"Cancel"}),e.jsx(C,{type:"button",size:"sm",tone:"accent",title:n?K:void 0,disabled:n||g===null||I!==null,onClick:()=>{g&&Ce(g)},children:"Close bead"})]}),children:e.jsx("p",{className:"text-body text-fg-muted",children:"Close this bead? It will be marked closed and drop out of the open queue."})}),e.jsx(ge,{open:Y,onClose:()=>{L||P(!1)},title:"New bead",caption:"Create and sling",widthClass:"max-w-2xl",footer:e.jsxs(e.Fragment,{children:[e.jsx(C,{type:"button",size:"sm",tone:"quiet",disabled:L,onClick:()=>P(!1),children:"Cancel"}),e.jsx(C,{type:"submit",form:"new-bead-form",size:"sm",title:n?K:void 0,disabled:n||L||F.trim().length===0||y.trim().length===0,children:L?"Creating":"Create and sling"})]}),children:e.jsxs("form",{id:"new-bead-form",className:"space-y-5",onSubmit:s=>{s.preventDefault(),Se()},children:[le&&e.jsx("p",{className:"text-accent",role:"alert",children:le}),e.jsxs("label",{className:"block space-y-2 text-body",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Title"}),e.jsx("input",{value:F,onChange:s=>re(s.target.value),required:!0,className:"w-full rounded-sm border border-rule bg-transparent px-3 py-2 text-body text-fg focus-mark"})]}),e.jsxs("label",{className:"block space-y-2 text-body",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Body"}),e.jsx("textarea",{value:X,onChange:s=>oe(s.target.value),rows:5,className:"w-full rounded-sm border border-rule bg-transparent px-3 py-2 text-body text-fg focus-mark"})]}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsxs("label",{className:"block space-y-2 text-body",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Rig"}),e.jsxs("select",{value:R,onChange:s=>ke(s.target.value),className:"w-full rounded-sm border border-rule bg-transparent px-3 py-2 text-body text-fg focus-mark",children:[w.length===0&&e.jsx("option",{value:"",children:"all rigs"}),w.map(s=>e.jsx("option",{value:s,children:s},s))]})]}),e.jsxs("label",{className:"block space-y-2 text-body",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Agent"}),e.jsx("select",{value:y,onChange:s=>_(s.target.value),required:!0,className:"w-full rounded-sm border border-rule bg-transparent px-3 py-2 text-body text-fg focus-mark",children:M.map(s=>e.jsx("option",{value:s.name,children:s.display_name??s.name},s.name))})]})]})]})})]})}function ft(t){const n=t?.trim();return n&&n.length>0?n:null}function bt(t,n,l){if(l!==N&&t.length===0)return`No beads on ${l}.`;const a=t.filter(d=>d.status==="open").length,i=t.filter(d=>d.status==="in_progress").length,r=t.filter(d=>d.status==="blocked").length,c=[];if(a>0&&c.push(`${a} open`),i>0&&c.push(`${i} in progress`),r>0&&c.push(`${r} blocked`),c.length===0)return"Nothing on the queue.";let u=`${c.join(", ")}.`;return l!==N&&(u=`${l}: ${u}`),n>t.length&&(u+=` Showing ${t.length} of ${n}.`),u}export{At as BeadsPage}; diff --git a/internal/api/dashboardspa/dist/assets/Beads-DJjixOgD.js b/internal/api/dashboardspa/dist/assets/Beads-DJjixOgD.js deleted file mode 100644 index 6ddf91c7c3..0000000000 --- a/internal/api/dashboardspa/dist/assets/Beads-DJjixOgD.js +++ /dev/null @@ -1 +0,0 @@ -import{j as e,S as je,B as w,r as o,I as L,J as X,a as Le,g as Fe,K as Te,b as Y,c as De,l as qe,f as ze,z as fe,R as xe,i as J,H as He,G as Ke}from"./index-C20tCZFz.js";import{b as Ve,r as Ge}from"./routeHighlight-B30gQO2o.js";import{B as Ue}from"./BeadDetailModal-BtVrX_Fu.js";import{u as Ye,F as Je}from"./useListFilters-C0Eq1DLc.js";import{L as Xe,f as Qe}from"./projectOf-CwPPScnJ.js";import{M as be}from"./constants-DBKWGg29.js";import{P as We}from"./PageHeader-D_D-jYn1.js";import{l as Ze}from"./agentReads-C0EYRgYm.js";import"./format-fte2CeYD.js";import"./Field-Dsl4x4KL.js";import"./LiveSessionPeek-jm19JJ4Z.js";import"./time-D9v0saHV.js";function et(n){if(n===void 0)return null;const s=n.indexOf("?");if(s<0)return null;const l=new URLSearchParams(n.slice(s+1)).get("bead");return l!==null&&l.length>0?l:null}function tt({items:n,onOpen:s}){const l=n.filter(a=>a.severity==="attention"||a.severity==="watch");return l.length===0?null:e.jsxs("section",{"aria-labelledby":"beads-attention-title",className:"mb-8 space-y-3",children:[e.jsxs("h2",{id:"beads-attention-title",className:"text-label uppercase tracking-wider text-fg-muted",children:["Needs you ",e.jsxs("span",{className:"tnum text-fg",children:["(",l.length,")"]})]}),e.jsx("ul",{className:"space-y-2",children:l.map(a=>{const i=et(a.href);return e.jsxs("li",{className:"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1",children:[e.jsxs("div",{className:"min-w-0 space-y-0.5",children:[e.jsx(je,{tone:a.severity==="attention"?"stuck":"warn",label:a.title}),a.summary!==void 0&&e.jsx("p",{className:"text-body text-fg-muted",children:a.summary})]}),i!==null&&e.jsx("div",{className:"flex items-center gap-2",children:e.jsx(w,{type:"button",size:"sm",tone:"quiet",onClick:()=>s(i),children:"Open"})})]},a.id)})})]})}const ae=[{id:"ready",label:"ready"},{id:"open",label:"open"},{id:"in_progress",label:"in progress"},{id:"blocked",label:"blocked"},{id:"done",label:"done"}];function st(n){const s=new Set,l=[];for(const a of n.needs??[])a.length===0||s.has(a)||(s.add(a),l.push({id:a,kind:"needs"}));for(const a of n.dependencies??[]){const i=a.depends_on_id;i.length===0||s.has(i)||(s.add(i),l.push({id:i,kind:a.type}))}return l}function nt(n){return(n.needs??[]).filter(s=>s.length>0)}function at(n){switch(n.bead.status){case"in_progress":return"in_progress";case"blocked":return"blocked";case"closed":return"done";default:return n.ready?"ready":"open"}}function lt(n,s){const l=n.bead.priority??Number.POSITIVE_INFINITY,a=s.bead.priority??Number.POSITIVE_INFINITY;return l!==a?l-a:n.bead.ids.bead.id?1:0}function rt(n){const s=new Map;for(const r of n)s.set(r.id,r);const l=new Map,a=new Map;for(const r of n){const c=st(r).map(({id:m,kind:g})=>({id:m,kind:g,bead:s.get(m)??null})),u=c.some(m=>m.bead===null),d=nt(r),h=r.status==="open"&&d.every(m=>s.get(m)?.status==="closed"),p={bead:r,deps:c,blocks:[],ready:h,hasUnresolvedDeps:u,column:"open"};p.column=at(p),a.set(r.id,p);for(const m of c){if(m.bead===null)continue;const g=l.get(m.id);g?g.push(r):l.set(m.id,[r])}}for(const[r,c]of l){const u=a.get(r);u&&(u.blocks=[...c].sort((d,h)=>d.idh.id?1:0))}const i=Ne();for(const r of a.values())i[r.column].push(r);for(const r of ae)i[r.id].sort(lt);return{nodes:a,columns:i}}function Ne(){return{ready:[],open:[],in_progress:[],blocked:[],done:[]}}function ot(n,s){const l=Ne();for(const a of ae)l[a.id]=n.columns[a.id].filter(i=>s.has(i.bead.id));return l}function it({node:n,selected:s,attentionSeverity:l=null,onSelect:a}){const{bead:i,deps:r,blocks:c,hasUnresolvedDeps:u}=n,d=o.useRef(null),h=r.length,p=c.length,m=h>0||p>0,{className:g="",...R}=Ve(l);return o.useEffect(()=>{s&&d.current?.scrollIntoView?.({block:"center",inline:"nearest"})},[s]),e.jsx("li",{ref:d,...R,className:`px-2 py-2 -mx-2 rounded-sm transition-colors duration-150 ease-out-quart ${s?"bg-surface-tint":"hover:bg-surface-tint/60"} ${g}`,children:e.jsxs("button",{type:"button",onClick:()=>a(i.id),className:"text-left w-full focus-mark rounded-sm","aria-pressed":s,title:`Select ${i.id}`,children:[e.jsxs("span",{className:"flex items-baseline gap-2",children:[e.jsx("span",{className:"text-fg-faint","aria-hidden":"true",children:s?"▸":" "}),e.jsx("span",{className:`min-w-0 line-clamp-2 text-body ${s?"text-fg font-medium":"text-fg"}`,children:i.title})]}),e.jsxs("span",{className:"flex items-baseline gap-3 pl-4 mt-0.5 text-label uppercase tracking-wider text-fg-faint",children:[e.jsx("span",{className:"tnum",children:i.id}),i.priority!=null&&e.jsxs("span",{className:"tnum",children:["P",i.priority]}),m&&e.jsxs("span",{className:"tnum normal-case tracking-normal",children:[h>0&&`needs ${h}`,h>0&&p>0&&" · ",p>0&&`blocks ${p}`]}),u&&e.jsx("span",{className:"normal-case tracking-normal text-warn",children:"unresolved"})]})]})})}function ct({columns:n,selectedId:s,attentionSeverity:l,onSelect:a}){return e.jsx("div",{className:"grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-x-8 gap-y-8",children:ae.map(i=>{const r=n[i.id],u=i.id==="blocked"&&r.length>0?"text-accent":"text-fg-muted";return e.jsxs("section",{"aria-label":i.label,children:[e.jsxs("header",{className:"flex items-baseline justify-between border-b border-rule pb-2 mb-3",children:[e.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-muted",children:i.label}),e.jsx("span",{className:`text-label tnum ${u}`,children:r.length})]}),r.length===0?e.jsx("p",{className:"text-body text-fg-faint italic",children:"·"}):e.jsx("ul",{className:"space-y-1",children:r.map(d=>e.jsx(it,{node:d,selected:d.bead.id===s,attentionSeverity:l?.(d.bead.id)??null,onSelect:a},d.bead.id))})]},i.id)})})}function dt({label:n,count:s,graph:l,ids:a,selectedId:i,attentionSeverity:r,onSelect:c}){const u=ot(l,a);return e.jsxs("section",{"aria-label":n,children:[e.jsxs("header",{className:"flex items-baseline justify-between border-b border-rule pb-2 mb-4",children:[e.jsx("h2",{className:"text-headline text-fg",children:n}),e.jsx("span",{className:"text-label tnum text-fg-muted",children:s})]}),e.jsx(ct,{columns:u,selectedId:i,...r===void 0?{}:{attentionSeverity:r},onSelect:c})]})}function ut(n,s){const l=n?.trim();if(!l)return;const a=s.find(r=>r.name===l);return a?a.name:s.find(r=>r.path===l)?.name}function mt(n){return Array.from(new Set(n.map(s=>s.name.trim()).filter(s=>s.length>0))).sort((s,l)=>s.localeCompare(l))}async function pt(){const n=await L().listRigs(X("list supervisor rigs"));return{...n,items:n.items??[]}}async function gt(n,s){const l=s?.trim()??"";await L().closeBead(X("close supervisor bead"),n,l.length===0?void 0:{reason:l})}async function ht(n){const s=n.trim();if(s.length===0)throw new Error("agent alias is required");await L().nudgeAgent(X("nudge supervisor agent"),s)}async function ft(n){const s=n.title.trim(),l=n.description.trim(),a=n.rig.trim(),i=n.target.trim();if(s.length===0)throw new Error("bead title is required");if(i.length===0)throw new Error("sling target is required");const r=X("create and sling supervisor bead"),c={title:s};l.length>0&&(c.description=l);const u=await L().createBead(r,c),d={bead:u.id,target:i};a.length>0&&(d.rig=a);const h=await L().sling(r,d);return{bead:u,sling:h}}const xt=new Set,v="",we="closed",bt=1e4,ye=[{id:"open",label:"open",match:n=>n.status==="open"},{id:"in_progress",label:"in progress",match:n=>n.status==="in_progress"},{id:"blocked",label:"blocked",match:n=>n.status==="blocked"},{id:we,label:"closed",match:n=>n.status==="closed"}],yt=n=>[n.id,n.title,n.assignee,...n.labels??[]];function Mt(){const n=Le(),s=Fe(),a=He()??"no-city",[i]=Te(),r=jt(i.get("bead")),[c,u]=o.useState(v),[d,h]=o.useState(!1),[p,m]=o.useState(r),[g,R]=o.useState(null),[le,_]=o.useState(""),[k,re]=o.useState(null),[F,S]=o.useState(null),[Q,T]=o.useState(!1),[D,oe]=o.useState(!1),[ie,W]=o.useState(null),[q,ce]=o.useState(""),[Z,de]=o.useState(""),[A,ue]=o.useState(""),[y,$]=o.useState(""),{data:I,loading:z,error:me,refresh:E}=Y(`beads:board:${a}:${c}:${d?"all":"open"}`,()=>De({includeClosed:d,...c===v?{}:{rigFilter:c}})),ve=o.useMemo(()=>I?.items??[],[I]),pe=I?.total??0,ee=I?.upstream_total,te=I?.upstream_fetched,Ce=I?.fetch_limit,H=I!==void 0,K=Y(`sessions:${a}`,qe),ke=o.useMemo(()=>K.data?.items??[],[K.data]),M=Y(`agents:${a}`,Ze),N=o.useMemo(()=>M.data?.items??[],[M.data]),V=Y(`rigs:${a}`,pt),G=o.useMemo(()=>V.data?.items??[],[V.data]),C=o.useMemo(()=>mt(G),[G]),B=o.useCallback(t=>ut(t.rig,G),[G]),O=o.useMemo(()=>A.length===0?N:N.filter(t=>B(t)===A),[N,B,A]);o.useEffect(()=>{if(Q){if(O.length===0){y.length>0&&$("");return}O.some(t=>t.name===y)||$(O[0]?.name??"")}},[Q,O,y]),o.useEffect(()=>{c!==v&&!C.includes(c)&&u(v)},[C,c]);const U=ve,b=Ye({viewKey:"beads",rows:U,projectOf:Qe,searchOf:yt,chips:ye}),{toggleChip:ge}=b,Se=o.useCallback(t=>{t===we&&h(f=>!f),ge(t)},[ge]);ze([Ke.bead],()=>{E()},{coalesceMs:bt}),o.useEffect(()=>{r!==null&&m(r)},[r]);const se=o.useCallback(async(t,f,x)=>{if(!s){re({id:t.id,action:f}),S(null);try{if(f==="close")await gt(t.id,x),R(null),_(""),S({tone:"ok",text:`Closed ${t.id}.`});else{const j=t.assignee?.trim()??"";if(j.length===0)throw new Error("Assigned agent is required before nudging.");await ht(j),S({tone:"ok",text:`Nudged ${j}.`})}await E()}catch(j){S({tone:"error",text:fe(j,`${f} failed`)})}finally{re(null)}}},[s,E]),Ie=o.useCallback(()=>{const t=C[0]??"",f=N.find(x=>t.length===0||B(x)===t);ce(""),de(""),ue(t),$(f?.name??""),W(null),S(null),T(!0)},[N,B,C]),Be=o.useCallback(t=>{if(ue(t),!N.some(x=>x.name===y&&(t.length===0||B(x)===t))){const x=N.find(j=>t.length===0||B(j)===t);$(x?.name??"")}},[N,B,y]),Re=o.useCallback(async()=>{if(!s){oe(!0),W(null);try{const t=await ft({title:q,description:Z,rig:A,target:y});S({tone:"ok",text:`Created ${t.bead.id} and slung to ${y}.`}),T(!1),await E()}catch(t){W(fe(t,"create and sling failed"))}finally{oe(!1)}}},[y,Z,A,q,s,E]),P=o.useMemo(()=>b.groups.flatMap(t=>t.rows),[b.groups]),ne=o.useMemo(()=>rt(P),[P]),Ae=o.useMemo(()=>{const t=new Map;for(const f of b.groups)t.set(f.projectKey,new Set(f.rows.map(x=>x.id)));return t},[b.groups]),Ee=o.useMemo(()=>P.find(t=>t.id===p)??null,[P,p]),_e=o.useMemo(()=>p===null?null:ne.nodes.get(p)??null,[ne,p]),$e=o.useMemo(()=>t=>Ge(n,"beads",t),[n]),Me=o.useCallback(t=>{const f=t.assignee?.trim()??"",x=k!==null,j=k?.id===t.id?k.action.replace("_"," "):null,he=s?J:void 0;return e.jsxs("div",{className:"flex flex-wrap items-center justify-end gap-2",children:[s&&e.jsx(xe,{}),j&&e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint",children:j}),e.jsx(w,{type:"button",size:"sm",tone:"quiet",title:he,disabled:s||x||t.status==="closed",onClick:()=>{_(""),S(null),R(t)},children:"Close"}),e.jsx(w,{type:"button",size:"sm",tone:"quiet",title:he,disabled:s||x||f.length===0,onClick:()=>{se(t,"nudge")},children:"Nudge"})]})},[k,s,se]),Oe=o.useMemo(()=>H?Nt(U,pe,c):"Loading beads.",[U,H,pe,c]),Pe=typeof ee=="number"&&typeof te=="number"&&te{E()},disabled:z,children:z&&!H?"Loading":z?"Refreshing":"Refresh"})]})}),e.jsxs("div",{className:"space-y-2 mb-6 text-body text-fg-muted max-w-prose",children:[Pe&&e.jsx("p",{className:"text-warn",children:e.jsx(je,{tone:"warn",label:`Fetch window covered ${te} of ${ee} store beads. Raise the fetch limit (currently ${Ce??"?"}) if engineering work sits past the window.`})}),c!==v&&e.jsxs("p",{children:["Filtering by rig ",e.jsx("span",{className:"text-accent",children:c}),"."," ",e.jsx("button",{type:"button",onClick:()=>u(v),className:"text-fg-muted hover:text-fg focus-mark underline decoration-dotted underline-offset-2 rounded-sm",children:"Clear"})]}),F&&e.jsx("p",{className:F.tone==="error"?"text-accent":"text-fg-muted",role:F.tone==="error"?"alert":"status",children:F.text})]}),e.jsx(tt,{items:n.byDomain.beads.items,onOpen:m}),e.jsxs("div",{className:"mb-6 space-y-3",children:[e.jsx(Xe,{value:b.search,onChange:b.setSearch,placeholder:"Search beads by id, title, label, assignee",matchCount:b.totalMatches,totalCount:U.length,ariaLabel:"Search beads"}),e.jsxs("div",{className:"flex flex-wrap items-baseline gap-x-8 gap-y-3",children:[e.jsx(Je,{chips:ye,activeIds:b.activeChipIds,onToggle:Se,legend:"Status"}),C.length>1&&e.jsxs("label",{className:"flex items-baseline gap-2 text-label",children:[e.jsx("span",{className:"uppercase tracking-wider text-fg-muted",children:"Rig"}),e.jsxs("select",{value:c,onChange:t=>u(t.target.value),"aria-label":"Rig filter",className:"text-label uppercase tracking-wider text-fg-muted bg-transparent border-0 focus-mark cursor-pointer hover:text-fg transition-colors duration-150 ease-out-quart",children:[e.jsx("option",{value:v,children:"all rigs"}),C.map(t=>e.jsx("option",{value:t,children:t},t))]})]})]})]}),!H&&z?e.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading beads."}):P.length===0?e.jsx("p",{className:"text-body text-fg-muted italic",children:b.search.length>0||b.activeChipIds.size>0?"No beads match the current search or filter.":"Nothing on the queue right now."}):e.jsx("div",{className:"space-y-12",children:b.groups.map(t=>e.jsx(dt,{label:t.project,count:t.totalInProject,graph:ne,ids:Ae.get(t.projectKey)??xt,selectedId:p,attentionSeverity:$e,onSelect:m},t.projectKey))}),e.jsx(Ue,{open:p!==null,onClose:()=>m(null),beadId:p,initialBead:Ee,depNode:_e,sessions:ke,onOpenBead:m,renderActions:Me}),e.jsx(be,{open:g!==null,onClose:()=>{k===null&&(R(null),_(""))},title:g?`Close ${g.id}`:"Close bead",caption:g?.title,widthClass:"max-w-xl",footer:e.jsxs(e.Fragment,{children:[e.jsx(w,{type:"button",size:"sm",tone:"quiet",disabled:k!==null,onClick:()=>{R(null),_("")},children:"Cancel"}),e.jsx(w,{type:"button",size:"sm",tone:"accent",title:s?J:void 0,disabled:s||g===null||k!==null,onClick:()=>{g&&se(g,"close",le)},children:"Close bead"})]}),children:e.jsxs("label",{className:"block space-y-2 text-body",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Reason"}),e.jsx("textarea",{value:le,onChange:t=>_(t.target.value),rows:4,placeholder:"Optional close reason",className:"w-full rounded-sm border border-rule bg-transparent px-3 py-2 text-body text-fg focus-mark"})]})}),e.jsx(be,{open:Q,onClose:()=>{D||T(!1)},title:"New bead",caption:"Create and sling",widthClass:"max-w-2xl",footer:e.jsxs(e.Fragment,{children:[e.jsx(w,{type:"button",size:"sm",tone:"quiet",disabled:D,onClick:()=>T(!1),children:"Cancel"}),e.jsx(w,{type:"submit",form:"new-bead-form",size:"sm",title:s?J:void 0,disabled:s||D||q.trim().length===0||y.trim().length===0,children:D?"Creating":"Create and sling"})]}),children:e.jsxs("form",{id:"new-bead-form",className:"space-y-5",onSubmit:t=>{t.preventDefault(),Re()},children:[ie&&e.jsx("p",{className:"text-accent",role:"alert",children:ie}),e.jsxs("label",{className:"block space-y-2 text-body",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Title"}),e.jsx("input",{value:q,onChange:t=>ce(t.target.value),required:!0,className:"w-full rounded-sm border border-rule bg-transparent px-3 py-2 text-body text-fg focus-mark"})]}),e.jsxs("label",{className:"block space-y-2 text-body",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Body"}),e.jsx("textarea",{value:Z,onChange:t=>de(t.target.value),rows:5,className:"w-full rounded-sm border border-rule bg-transparent px-3 py-2 text-body text-fg focus-mark"})]}),e.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[e.jsxs("label",{className:"block space-y-2 text-body",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Rig"}),e.jsxs("select",{value:A,onChange:t=>Be(t.target.value),className:"w-full rounded-sm border border-rule bg-transparent px-3 py-2 text-body text-fg focus-mark",children:[C.length===0&&e.jsx("option",{value:"",children:"all rigs"}),C.map(t=>e.jsx("option",{value:t,children:t},t))]})]}),e.jsxs("label",{className:"block space-y-2 text-body",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Agent"}),e.jsx("select",{value:y,onChange:t=>$(t.target.value),required:!0,className:"w-full rounded-sm border border-rule bg-transparent px-3 py-2 text-body text-fg focus-mark",children:O.map(t=>e.jsx("option",{value:t.name,children:t.display_name??t.name},t.name))})]})]})]})})]})}function jt(n){const s=n?.trim();return s&&s.length>0?s:null}function Nt(n,s,l){if(l!==v&&n.length===0)return`No beads on ${l}.`;const a=n.filter(d=>d.status==="open").length,i=n.filter(d=>d.status==="in_progress").length,r=n.filter(d=>d.status==="blocked").length,c=[];if(a>0&&c.push(`${a} open`),i>0&&c.push(`${i} in progress`),r>0&&c.push(`${r} blocked`),c.length===0)return"Nothing on the queue.";let u=`${c.join(", ")}.`;return l!==v&&(u=`${l}: ${u}`),s>n.length&&(u+=` Showing ${n.length} of ${s}.`),u}export{Mt as BeadsPage}; diff --git a/internal/api/dashboardspa/dist/assets/Field-Dsl4x4KL.js b/internal/api/dashboardspa/dist/assets/Field-9mEFYSnz.js similarity index 85% rename from internal/api/dashboardspa/dist/assets/Field-Dsl4x4KL.js rename to internal/api/dashboardspa/dist/assets/Field-9mEFYSnz.js index 8fa23d4197..11b0dc11f1 100644 --- a/internal/api/dashboardspa/dist/assets/Field-Dsl4x4KL.js +++ b/internal/api/dashboardspa/dist/assets/Field-9mEFYSnz.js @@ -1 +1 @@ -import{j as e}from"./index-C20tCZFz.js";function i({label:t,children:s,variant:a="definition"}){return a==="form"?e.jsxs("label",{className:"block space-y-1.5",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:t}),s]}):e.jsxs("div",{children:[e.jsx("dt",{className:"text-label uppercase tracking-wider text-fg-faint mb-1",children:t}),e.jsx("dd",{className:"text-body text-fg",children:s})]})}export{i as F}; +import{j as e}from"./index-CJ6RRl2D.js";function i({label:t,children:s,variant:a="definition"}){return a==="form"?e.jsxs("label",{className:"block space-y-1.5",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:t}),s]}):e.jsxs("div",{children:[e.jsx("dt",{className:"text-label uppercase tracking-wider text-fg-faint mb-1",children:t}),e.jsx("dd",{className:"text-body text-fg",children:s})]})}export{i as F}; diff --git a/internal/api/dashboardspa/dist/assets/FormulaRunDetail-CFys0Xia.js b/internal/api/dashboardspa/dist/assets/FormulaRunDetail-C-9EnNQt.js similarity index 96% rename from internal/api/dashboardspa/dist/assets/FormulaRunDetail-CFys0Xia.js rename to internal/api/dashboardspa/dist/assets/FormulaRunDetail-C-9EnNQt.js index cb33df4261..0ed3d03062 100644 --- a/internal/api/dashboardspa/dist/assets/FormulaRunDetail-CFys0Xia.js +++ b/internal/api/dashboardspa/dist/assets/FormulaRunDetail-C-9EnNQt.js @@ -1,12 +1,12 @@ -import{j as d,r as j,S as Tr,X as Pe,Y as Oe,Z as Mr,_ as Or,x as rn,p as tn,b as Yn,q as Ir,K as Rr,f as Pr,u as $r,$ as Fr,L as Br,B as Gr,H as xr,G as wn}from"./index-C20tCZFz.js";import{P as Lr}from"./PageHeader-D_D-jYn1.js";import{u as Ur,R as zr,B as Kr}from"./BeadDetailModal-BtVrX_Fu.js";import{u as Hr,S as Wr}from"./LiveSessionPeek-jm19JJ4Z.js";import{S as _n}from"./StageLadder-B3yZa5o4.js";import"./format-fte2CeYD.js";import"./Field-Dsl4x4KL.js";import"./constants-DBKWGg29.js";import"./time-D9v0saHV.js";const Vr=/^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,127}$/,Nn={pending:"pending",ready:"ready",running:"running",active:"running",done:"done",completed:"done",failed:"failed",blocked:"blocked",skipped:"skipped"};function Xr({node:e,selected:r,onToggle:n}){const t=Jr(e.constructKind),a=Qr(e.status),s=e.iterationSummary.kind==="stacked"?`${e.iterationSummary.iterationCount} iterations, showing ${e.iterationSummary.visibleIteration}`:null,i=e.attemptSummary.kind==="tracked"&&e.attemptSummary.badge.kind==="bounded"?` · attempt ${e.attemptSummary.badge.label}${Zr(e)}`:"";return d.jsxs("button",{type:"button","aria-pressed":r,onClick:()=>n(e.id),className:`focus-mark w-full text-left px-4 py-3 bg-transparent transition-colors duration-150 ease-out-quart ${t} ${r?"text-fg border-accent bg-surface-tint ring-2 ring-accent/45 ring-offset-2 ring-offset-surface":"text-fg border-rule hover:border-fg-faint hover:bg-surface-tint"}`,children:[d.jsxs("div",{className:"flex items-start justify-between gap-3",children:[d.jsxs("div",{children:[d.jsx("p",{className:"text-body text-fg leading-snug",children:e.title}),d.jsxs("p",{className:"mt-1 text-label uppercase tracking-wider text-fg-faint",children:[Yr(e.constructKind),i]})]}),d.jsxs("span",{className:`text-label uppercase tracking-wider shrink-0 ${a}`,children:[qr(e.status)," ",Nn[e.status]]})]}),s&&d.jsxs("p",{className:"mt-2 text-label uppercase tracking-wider text-fg-faint tnum",children:["stacked history: ",s]}),e.controlBadges.length>0&&d.jsx("div",{className:"mt-2 flex flex-wrap gap-2",children:e.controlBadges.map(o=>d.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-muted border border-rule px-1.5 py-0.5",children:[o.label,": ",Nn[o.status]]},o.id))})]})}function Zr(e){return e.attemptSummary.kind==="tracked"&&e.attemptSummary.active.kind==="running"?` · running attempt ${e.attemptSummary.active.value}`:""}function Yr(e){switch(e){case"run-root":return"run root";case"run-finalize":return"finalize";case"step":case"retry":case"check-loop":case"scope":case"condition":case"fanout":case"expansion":case"scope-check":case"spec":case"control":case"unknown":return e.replace(/-/g," ")}}function Jr(e){switch(e){case"run-root":return"formula-run-node-shape-root";case"step":case"unknown":return"formula-run-node-shape-step";case"retry":return"formula-run-node-shape-retry";case"check-loop":return"formula-run-node-shape-check-loop";case"scope":return"formula-run-node-shape-scope";case"condition":return"formula-run-node-shape-condition";case"fanout":return"formula-run-node-shape-fanout";case"expansion":return"formula-run-node-shape-expansion";case"scope-check":case"run-finalize":case"spec":case"control":return"formula-run-node-shape-control"}}function Qr(e){switch(e){case"failed":case"blocked":return"text-accent";case"active":case"running":case"ready":return"text-fg";case"completed":case"done":return"text-fg-muted";case"pending":case"skipped":return"text-fg-faint"}}function qr(e){switch(e){case"completed":case"done":return"✓";case"active":case"running":return"●";case"failed":case"blocked":return"!";case"skipped":return"∅";case"pending":case"ready":return"·"}}function et({detail:e,selectedNodeId:r,onToggleNode:n}){const t=nt(e),a=rt(e);return t.length===0?d.jsx("p",{className:"text-body text-fg-muted italic",children:"No graph nodes have materialized for this formula run."}):d.jsxs("section",{"aria-label":"Formula run graph",children:[d.jsx("div",{className:"flex items-baseline justify-between gap-4",children:d.jsx("h2",{className:"text-title text-fg",children:"Formula Graph"})}),d.jsx("ol",{className:"mt-5 space-y-3 relative",children:t.map((s,i)=>{const o=a.get(s.id),l=i>0?a.get(t[i-1]?.id??""):void 0,u=o!==void 0&&o!==l;return d.jsxs("li",{className:"relative pl-6",children:[u&&d.jsx("p",{className:"mb-1 text-label uppercase tracking-wider text-fg-faint",children:o}),ir.visibleInGraph!==!1)}function rt(e){const r=new Map;for(const n of e.lanes)for(const t of n.nodeIds)r.set(t,n.label);return r}function jn(e,r){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var t=Object.getOwnPropertySymbols(e);r&&(t=t.filter((function(a){return Object.getOwnPropertyDescriptor(e,a).enumerable}))),n.push.apply(n,t)}return n}function O(e){for(var r=1;r=0||(c[l]=i[l]);return c})(e,r);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(e);for(t=0;t=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(a[n]=e[n])}return a}function x(e,r){return at(e)||(function(n,t){var a=n==null?null:typeof Symbol<"u"&&n[Symbol.iterator]||n["@@iterator"];if(a!=null){var s,i,o,l,u=[],c=!0,f=!1;try{if(o=(a=a.call(n)).next,t===0){if(Object(a)!==a)return;c=!1}else for(;!(c=(s=o.call(a)).done)&&(u.push(s.value),u.length!==t);c=!0);}catch(h){f=!0,i=h}finally{try{if(!c&&a.return!=null&&(l=a.return(),Object(l)!==l))return}finally{if(f)throw i}}return u}})(e,r)||an(e,r)||it()}function tt(e){return(function(r){if(Array.isArray(r))return Ve(r)})(e)||st(e)||an(e)||(function(){throw new TypeError(`Invalid attempt to spread non-iterable instance. +import{j as d,r as j,S as Tr,W as Pe,X as Oe,Y as Mr,Z as Or,x as rn,p as tn,b as Jn,q as Ir,J as Rr,f as Pr,u as $r,_ as Fr,L as Br,B as Gr,F as xr,G as wn}from"./index-CJ6RRl2D.js";import{P as Lr}from"./PageHeader-4s_Bnmfl.js";import{u as Ur,R as zr,B as Kr}from"./BeadDetailModal-3OfdZfdZ.js";import{u as Hr,S as Wr}from"./LiveSessionPeek-D86UU9cf.js";import{S as _n}from"./StageLadder-DMyiD1Pv.js";import"./format-fte2CeYD.js";import"./Field-9mEFYSnz.js";import"./constants-BJUwiA6r.js";import"./time-D9v0saHV.js";const Vr=/^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,127}$/,Nn={pending:"pending",ready:"ready",running:"running",active:"running",done:"done",completed:"done",failed:"failed",blocked:"blocked",skipped:"skipped"};function Xr({node:e,selected:r,onToggle:n}){const t=Yr(e.constructKind),a=Qr(e.status),s=e.iterationSummary.kind==="stacked"?`${e.iterationSummary.iterationCount} iterations, showing ${e.iterationSummary.visibleIteration}`:null,i=e.attemptSummary.kind==="tracked"&&e.attemptSummary.badge.kind==="bounded"?` · attempt ${e.attemptSummary.badge.label}${Zr(e)}`:"";return d.jsxs("button",{type:"button","aria-pressed":r,onClick:()=>n(e.id),className:`focus-mark w-full text-left px-4 py-3 bg-transparent transition-colors duration-150 ease-out-quart ${t} ${r?"text-fg border-accent bg-surface-tint ring-2 ring-accent/45 ring-offset-2 ring-offset-surface":"text-fg border-rule hover:border-fg-faint hover:bg-surface-tint"}`,children:[d.jsxs("div",{className:"flex items-start justify-between gap-3",children:[d.jsxs("div",{children:[d.jsx("p",{className:"text-body text-fg leading-snug",children:e.title}),d.jsxs("p",{className:"mt-1 text-label uppercase tracking-wider text-fg-faint",children:[Jr(e.constructKind),i]})]}),d.jsxs("span",{className:`text-label uppercase tracking-wider shrink-0 ${a}`,children:[qr(e.status)," ",Nn[e.status]]})]}),s&&d.jsxs("p",{className:"mt-2 text-label uppercase tracking-wider text-fg-faint tnum",children:["stacked history: ",s]}),e.controlBadges.length>0&&d.jsx("div",{className:"mt-2 flex flex-wrap gap-2",children:e.controlBadges.map(o=>d.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-muted border border-rule px-1.5 py-0.5",children:[o.label,": ",Nn[o.status]]},o.id))})]})}function Zr(e){return e.attemptSummary.kind==="tracked"&&e.attemptSummary.active.kind==="running"?` · running attempt ${e.attemptSummary.active.value}`:""}function Jr(e){switch(e){case"run-root":return"run root";case"run-finalize":return"finalize";case"step":case"retry":case"check-loop":case"scope":case"condition":case"fanout":case"expansion":case"scope-check":case"spec":case"control":case"unknown":return e.replace(/-/g," ")}}function Yr(e){switch(e){case"run-root":return"formula-run-node-shape-root";case"step":case"unknown":return"formula-run-node-shape-step";case"retry":return"formula-run-node-shape-retry";case"check-loop":return"formula-run-node-shape-check-loop";case"scope":return"formula-run-node-shape-scope";case"condition":return"formula-run-node-shape-condition";case"fanout":return"formula-run-node-shape-fanout";case"expansion":return"formula-run-node-shape-expansion";case"scope-check":case"run-finalize":case"spec":case"control":return"formula-run-node-shape-control"}}function Qr(e){switch(e){case"failed":case"blocked":return"text-accent";case"active":case"running":case"ready":return"text-fg";case"completed":case"done":return"text-fg-muted";case"pending":case"skipped":return"text-fg-faint"}}function qr(e){switch(e){case"completed":case"done":return"✓";case"active":case"running":return"●";case"failed":case"blocked":return"!";case"skipped":return"∅";case"pending":case"ready":return"·"}}function et({detail:e,selectedNodeId:r,onToggleNode:n}){const t=nt(e),a=rt(e);return t.length===0?d.jsx("p",{className:"text-body text-fg-muted italic",children:"No graph nodes have materialized for this formula run."}):d.jsxs("section",{"aria-label":"Formula run graph",children:[d.jsx("div",{className:"flex items-baseline justify-between gap-4",children:d.jsx("h2",{className:"text-title text-fg",children:"Formula Graph"})}),d.jsx("ol",{className:"mt-5 space-y-3 relative",children:t.map((s,i)=>{const o=a.get(s.id),l=i>0?a.get(t[i-1]?.id??""):void 0,u=o!==void 0&&o!==l;return d.jsxs("li",{className:"relative pl-6",children:[u&&d.jsx("p",{className:"mb-1 text-label uppercase tracking-wider text-fg-faint",children:o}),ir.visibleInGraph!==!1)}function rt(e){const r=new Map;for(const n of e.lanes)for(const t of n.nodeIds)r.set(t,n.label);return r}function jn(e,r){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var t=Object.getOwnPropertySymbols(e);r&&(t=t.filter((function(a){return Object.getOwnPropertyDescriptor(e,a).enumerable}))),n.push.apply(n,t)}return n}function O(e){for(var r=1;r=0||(c[l]=i[l]);return c})(e,r);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(e);for(t=0;t=0||Object.prototype.propertyIsEnumerable.call(e,n)&&(a[n]=e[n])}return a}function x(e,r){return at(e)||(function(n,t){var a=n==null?null:typeof Symbol<"u"&&n[Symbol.iterator]||n["@@iterator"];if(a!=null){var s,i,o,l,u=[],c=!0,f=!1;try{if(o=(a=a.call(n)).next,t===0){if(Object(a)!==a)return;c=!1}else for(;!(c=(s=o.call(a)).done)&&(u.push(s.value),u.length!==t);c=!0);}catch(h){f=!0,i=h}finally{try{if(!c&&a.return!=null&&(l=a.return(),Object(l)!==l))return}finally{if(f)throw i}}return u}})(e,r)||an(e,r)||it()}function tt(e){return(function(r){if(Array.isArray(r))return Ve(r)})(e)||st(e)||an(e)||(function(){throw new TypeError(`Invalid attempt to spread non-iterable instance. In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)})()}function at(e){if(Array.isArray(e))return e}function st(e){if(typeof Symbol<"u"&&e[Symbol.iterator]!=null||e["@@iterator"]!=null)return Array.from(e)}function an(e,r){if(e){if(typeof e=="string")return Ve(e,r);var n=Object.prototype.toString.call(e).slice(8,-1);return n==="Object"&&e.constructor&&(n=e.constructor.name),n==="Map"||n==="Set"?Array.from(e):n==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?Ve(e,r):void 0}}function Ve(e,r){(r==null||r>e.length)&&(r=e.length);for(var n=0,t=new Array(r);n=e.length?{done:!0}:{done:!1,value:e[t++]}},e:function(l){throw l},f:a}}throw new TypeError(`Invalid attempt to iterate non-iterable instance. -In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}var s,i=!0,o=!1;return{s:function(){n=n.call(e)},n:function(){var l=n.next();return i=l.done,l},e:function(l){o=!0,s=l},f:function(){try{i||n.return==null||n.return()}finally{if(o)throw s}}}}var Ce=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function _e(e,r){return e(r={exports:{}},r.exports),r.exports}var F=_e((function(e){(function(){var r={}.hasOwnProperty;function n(){for(var t=[],a=0;a-1?p.slice(0,y):_;switch(_){case"diff":v--;break e;case"deleted":case"new":var N=p.slice(y+1);N.indexOf("file mode")===0&&(i[_==="new"?"newMode":"oldMode"]=N.slice(10));break;case"similarity":i.similarity=parseInt(p.split(" ")[2],10);break;case"index":var C=p.slice(y+1).split(" "),S=C[0].split("..");i.oldRevision=S[0],i.newRevision=S[1],C[1]&&(i.oldMode=i.newMode=C[1]);break;case"copy":case"rename":var A=p.slice(y+1);A.indexOf("from")===0?i.oldPath=A.slice(5):i.newPath=A.slice(3),w=_;break;case"---":var k=p.slice(y+1),E=g[++v].slice(4);k==="/dev/null"?(E=E.slice(2),w="add"):E==="/dev/null"?(k=k.slice(2),w="delete"):(w="modify",k=k.slice(2),E=E.slice(2)),k&&(i.oldPath=k),E&&(i.newPath=E),h=5;break e}}i.type=w||"modify"}else if(b.indexOf("Binary")===0)i.isBinary=!0,i.type=b.indexOf("/dev/null and")>=0?"add":b.indexOf("and /dev/null")>=0?"delete":"modify",h=2,i=null;else if(h===5)if(b.indexOf("@@")===0){var D=/^@@\s+-([0-9]+)(,([0-9]+))?\s+\+([0-9]+)(,([0-9]+))?/.exec(b);o={content:b,oldStart:D[1]-0,newStart:D[4]-0,oldLines:D[3]-0||1,newLines:D[6]-0||1,changes:[]},i.hunks.push(o),l=o.oldStart,u=o.newStart}else{var B=b.slice(0,1),M={content:b.slice(1)};switch(B){case"+":M.type="insert",M.isInsert=!0,M.lineNumber=u,u++;break;case"-":M.type="delete",M.isDelete=!0,M.lineNumber=l,l++;break;case" ":M.type="normal",M.isNormal=!0,M.oldLineNumber=l,M.newLineNumber=u,l++,u++;break;case"\\":var R=o.changes[o.changes.length-1];R.isDelete||(i.newEndingNewLine=!1),R.isInsert||(i.oldEndingNewLine=!1)}M.type&&o.changes.push(M)}v++}return f}};e.exports=a})()}));function Ne(e){return e.type==="insert"}function Q(e){return e.type==="delete"}function ve(e){return e.type==="normal"}function ft(e,r){var n=r.nearbySequences==="zip"?(function(t){var a=t.reduce((function(s,i,o){var l=x(s,3),u=l[0],c=l[1],f=l[2];return c?Ne(i)&&f>=0?(u.splice(f+1,0,i),[u,i,f+2]):(u.push(i),[u,i,Q(i)&&Q(c)?f:o]):(u.push(i),[u,i,Q(i)?o:-1])}),[[],null,-1]);return x(a,1)[0]})(e.changes):e.changes;return O(O({},e),{},{isPlain:!1,changes:n})}function dt(e){var r=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},n=(function(t){if(t.startsWith("diff --git"))return t;var a=t.indexOf(` `),s=t.indexOf(` `,a+1),i=t.slice(0,a),o=t.slice(a+1,s),l=i.split(" ").slice(1,-3).join(" "),u=o.split(" ").slice(1,-3).join(" ");return["diff --git a/".concat(l," b/").concat(u),"index 1111111..2222222 100644","--- a/".concat(l),"+++ b/".concat(u),t.slice(s+1)].join(` -`)})(e.trimStart());return ct.parse(n).map((function(t){return(function(a,s){var i=a.hunks.map((function(o){return ft(o,s)}));return O(O({},a),{},{hunks:i})})(t,r)}))}function ht(e){return e[0]}function gt(e){return e[e.length-1]}function Xe(e){return["".concat(e,"Start"),"".concat(e,"Lines")]}function be(e){return e==="old"?function(r){return Ne(r)?-1:ve(r)?r.oldLineNumber:r.lineNumber}:function(r){return Q(r)?-1:ve(r)?r.newLineNumber:r.lineNumber}}function Qn(e,r){return function(n,t){var a=n[e],s=a+n[r];return t>=a&&t=s&&a-1},_t=function(e,r){var n=this.__data__,t=$e(n,e);return t<0?(++this.size,n.push([e,r])):n[t][1]=r,this};function ie(e){var r=-1,n=e==null?0:e.length;for(this.clear();++ro))return!1;var u=s.get(e),c=s.get(r);if(u&&c)return u==r&&c==e;var f=-1,h=!0,g=2&n?new aa:void 0;for(s.set(e,r),s.set(r,e);++f-1&&e%1==0&&e-1&&e%1==0&&e<=9007199254740991},T={};T["[object Float32Array]"]=T["[object Float64Array]"]=T["[object Int8Array]"]=T["[object Int16Array]"]=T["[object Int32Array]"]=T["[object Uint8Array]"]=T["[object Uint8ClampedArray]"]=T["[object Uint16Array]"]=T["[object Uint32Array]"]=!0,T["[object Arguments]"]=T["[object Array]"]=T["[object ArrayBuffer]"]=T["[object Boolean]"]=T["[object DataView]"]=T["[object Date]"]=T["[object Error]"]=T["[object Function]"]=T["[object Map]"]=T["[object Number]"]=T["[object Object]"]=T["[object RegExp]"]=T["[object Set]"]=T["[object String]"]=T["[object WeakMap]"]=!1;var _a=function(e){return de(e)&&ln(e.length)&&!!T[he(e)]},Na=function(e){return function(r){return e(r)}},Mn=_e((function(e,r){var n=r&&!r.nodeType&&r,t=n&&e&&!e.nodeType&&e,a=t&&t.exports===n&&nr.process,s=(function(){try{var i=t&&t.require&&t.require("util").types;return i||a&&a.binding&&a.binding("util")}catch{}})();e.exports=s})),On=Mn&&Mn.isTypedArray,lr=On?Na(On):_a,ja=Object.prototype.hasOwnProperty,Sa=function(e,r){var n=W(e),t=!n&&ir(e),a=!n&&!t&&Ze(e),s=!n&&!t&&!a&&lr(e),i=n||t||a||s,o=i?va(e.length,String):[],l=o.length;for(var u in e)!ja.call(e,u)||i&&(u=="length"||a&&(u=="offset"||u=="parent")||s&&(u=="buffer"||u=="byteLength"||u=="byteOffset")||or(u,l))||o.push(u);return o},ka=Object.prototype,Ca=function(e){var r=e&&e.constructor;return e===(typeof r=="function"&&r.prototype||ka)},Aa=(function(e,r){return function(n){return e(r(n))}})(Object.keys,Object),Ea=Object.prototype.hasOwnProperty,Da=function(e){if(!Ca(e))return Aa(e);var r=[];for(var n in Object(e))Ea.call(e,n)&&n!="constructor"&&r.push(n);return r},Ta=function(e){return e!=null&&ln(e.length)&&!tr(e)},un=function(e){return Ta(e)?Sa(e):Da(e)},In=function(e){return fa(e,un,ma)},Ma=Object.prototype.hasOwnProperty,Oa=function(e,r,n,t,a,s){var i=1&n,o=In(e),l=o.length;if(l!=In(r).length&&!i)return!1;for(var u=l;u--;){var c=o[u];if(!(i?c in r:Ma.call(r,c)))return!1}var f=s.get(e),h=s.get(r);if(f&&h)return f==r&&h==e;var g=!0;s.set(e,r),s.set(r,e);for(var m=i;++u1)return!1;if(e.length===1){var r=x(e,1)[0];return r.type==="text"&&!r.value}return!0}function hs(e){var r=e.changeKey,n=e.text,t=e.tokens,a=e.renderToken,s=fe(e,fs),i=a?function(o,l){return a(o,Gn,l)}:Gn;return d.jsx("td",O(O({},s),{},{"data-change-key":r,children:t?ds(t)?" ":t.map(i):n||" "}))}var mr=j.memo(hs);function vr(e,r){return function(){var n=r==="old"?hn(e):gn(e);return n===-1?void 0:n}}function br(e,r){return function(n){return e&&n?d.jsx("a",{href:r?"#"+r:void 0,children:n}):n}}function Ie(e,r){return r?function(n){e(),r(n)}:e}function xn(e,r,n,t){return j.useMemo((function(){var a=gr(e,(function(s){return function(i){return s&&s(r,i)}}));return a.onMouseEnter=Ie(n,a.onMouseEnter),a.onMouseLeave=Ie(t,a.onMouseLeave),a}),[e,n,t,r])}function Ln(e,r,n,t,a,s,i,o,l){var u={change:r,side:t,inHoverState:o,renderDefault:vr(r,t),wrapInAnchor:br(a,s)};return d.jsx("td",O(O({className:e},i),{},{"data-change-key":n,children:l(u)}))}function gs(e){var r,n,t,a=e.change,s=e.selected,i=e.tokens,o=e.className,l=e.generateLineClassName,u=e.gutterClassName,c=e.codeClassName,f=e.gutterEvents,h=e.codeEvents,g=e.hideGutter,m=e.gutterAnchor,v=e.generateAnchorID,b=e.renderToken,p=e.renderGutter,w=a.type,y=a.content,_=q(a),N=(r=x(j.useState(!1),2),n=r[0],t=r[1],[n,j.useCallback((function(){return t(!0)}),[]),j.useCallback((function(){return t(!1)}),[])]),C=x(N,3),S=C[0],A=C[1],k=C[2],E=j.useMemo((function(){return{change:a}}),[a]),D=xn(f,E,A,k),B=xn(h,E,A,k),M=v(a),R=l({changes:[a],defaultGenerate:function(){return o}}),G=F("diff-gutter","diff-gutter-".concat(w),u,{"diff-gutter-selected":s}),L=F("diff-code","diff-code-".concat(w),c,{"diff-code-selected":s});return d.jsxs("tr",{id:M,className:F("diff-line",R),children:[!g&&Ln(G,a,_,"old",m,M,D,S,p),!g&&Ln(G,a,_,"new",m,M,D,S,p),d.jsx(mr,O({className:L,changeKey:_,text:y,tokens:i,renderToken:b},B))]})}var ms=j.memo(gs);function vs(e){var r=e.hideGutter,n=e.element;return d.jsx("tr",{className:"diff-widget",children:d.jsx("td",{colSpan:r?1:3,className:"diff-widget-content",children:n})})}var bs=["hideGutter","selectedChanges","tokens","lineClassName"],ps=["hunk","widgets","className"];function ys(e){var r=e.hunk,n=e.widgets,t=e.className,a=fe(e,ps),s=(function(i,o){return i.reduce((function(l,u){var c=q(u);l.push(["change",c,u]);var f=o[c];return f&&l.push(["widget",c,f]),l}),[])})(r.changes,n);return d.jsx("tbody",{className:F("diff-hunk",t),children:s.map((function(i){return(function(o,l){var u=x(o,3),c=u[0],f=u[1],h=u[2],g=l.hideGutter,m=l.selectedChanges,v=l.tokens,b=l.lineClassName,p=fe(l,bs);if(c==="change"){var w=Q(h)?"old":"new",y=Q(h)?hn(h):gn(h),_=v?v[w][y-1]:null;return d.jsx(ms,O({className:b,change:h,hideGutter:g,selected:m.includes(f),tokens:_},p),"change".concat(f))}return c==="widget"?d.jsx(vs,{hideGutter:g,element:h},"widget".concat(f)):null})(i,a)}))})}var pr=0;function Ee(e,r,n,t){var a=j.useCallback((function(){return r(e)}),[e,r]),s=j.useCallback((function(){return r("")}),[r]);return j.useMemo((function(){var i=gr(t,(function(o){return function(l){return o&&o({side:e,change:n},l)}}));return i.onMouseEnter=Ie(a,i.onMouseEnter),i.onMouseLeave=Ie(s,i.onMouseLeave),i}),[n,t,a,e,s])}function Ke(e){var r=e.change,n=e.side,t=e.selected,a=e.tokens,s=e.gutterClassName,i=e.codeClassName,o=e.gutterEvents,l=e.codeEvents,u=e.anchorID,c=e.gutterAnchor,f=e.gutterAnchorTarget,h=e.hideGutter,g=e.hover,m=e.renderToken,v=e.renderGutter;if(!r){var b=F("diff-gutter","diff-gutter-omit",s),p=F("diff-code","diff-code-omit",i);return[!h&&d.jsx("td",{className:b},"gutter"),d.jsx("td",{className:p},"code")]}var w=r.type,y=r.content,_=q(r),N=n===pr?"old":"new",C=O({id:u||void 0,className:F("diff-gutter","diff-gutter-".concat(w),We({"diff-gutter-selected":t},"diff-line-hover-"+N,g),s),children:v({change:r,side:N,inHoverState:g,renderDefault:vr(r,N),wrapInAnchor:br(c,f)})},o),S=F("diff-code","diff-code-".concat(w),We({"diff-code-selected":t},"diff-line-hover-"+N,g),i);return[!h&&d.jsx("td",O(O({},C),{},{"data-change-key":_}),"gutter"),d.jsx(mr,O({className:S,changeKey:_,text:y,tokens:a,renderToken:m},l),"code")]}function ws(e){var r=e.className,n=e.oldChange,t=e.newChange,a=e.oldSelected,s=e.newSelected,i=e.oldTokens,o=e.newTokens,l=e.monotonous,u=e.gutterClassName,c=e.codeClassName,f=e.gutterEvents,h=e.codeEvents,g=e.hideGutter,m=e.generateAnchorID,v=e.generateLineClassName,b=e.gutterAnchor,p=e.renderToken,w=e.renderGutter,y=x(j.useState(""),2),_=y[0],N=y[1],C=Ee("old",N,n,f),S=Ee("new",N,t,f),A=Ee("old",N,n,h),k=Ee("new",N,t,h),E=n&&m(n),D=t&&m(t),B=v({changes:[n,t],defaultGenerate:function(){return r}}),M={monotonous:l,hideGutter:g,gutterClassName:u,codeClassName:c,gutterEvents:f,codeEvents:h,renderToken:p,renderGutter:w},R=O(O({},M),{},{change:n,side:pr,selected:a,tokens:i,gutterEvents:C,codeEvents:A,anchorID:E,gutterAnchor:b,gutterAnchorTarget:E,hover:_==="old"}),G=O(O({},M),{},{change:t,side:1,selected:s,tokens:o,gutterEvents:S,codeEvents:k,anchorID:n===t?null:D,gutterAnchor:b,gutterAnchorTarget:n===t?E:D,hover:_==="new"});if(l)return d.jsx("tr",{className:F("diff-line",B),children:Ke(n?R:G)});var L=(function(X,ee){return X&&!ee?"diff-line-old-only":!X&&ee?"diff-line-new-only":X===ee?"diff-line-normal":"diff-line-compare"})(n,t);return d.jsxs("tr",{className:F("diff-line",L,B),children:[Ke(R),Ke(G)]})}var _s=j.memo(ws);function Ns(e){var r=e.hideGutter,n=e.oldElement,t=e.newElement;return e.monotonous?d.jsx("tr",{className:"diff-widget",children:d.jsx("td",{colSpan:r?1:2,className:"diff-widget-content",children:n||t})}):n===t?d.jsx("tr",{className:"diff-widget",children:d.jsx("td",{colSpan:r?2:4,className:"diff-widget-content",children:n})}):d.jsxs("tr",{className:"diff-widget",children:[d.jsx("td",{colSpan:r?1:2,className:"diff-widget-content",children:n}),d.jsx("td",{colSpan:r?1:2,className:"diff-widget-content",children:t})]})}var js=["selectedChanges","monotonous","hideGutter","tokens","lineClassName"],Ss=["hunk","widgets","className"];function De(e,r){return(e?q(e):"00")+(r?q(r):"00")}function ks(e){var r=e.hunk,n=e.widgets,t=e.className,a=fe(e,Ss),s=(function(i,o){for(var l=function(p){if(!p)return null;var w=q(p);return o[w]||null},u=[],c=0;ct.length?n:t,l=n.length>t.length?t:n,u=o.indexOf(l);if(u!=-1)return i=[new r.Diff(1,o.substring(0,u)),new r.Diff(0,l),new r.Diff(1,o.substring(u+l.length))],n.length>t.length&&(i[0][0]=i[2][0]=-1),i;if(l.length==1)return[new r.Diff(-1,n),new r.Diff(1,t)];var c=this.diff_halfMatch_(n,t);if(c){var f=c[0],h=c[1],g=c[2],m=c[3],v=c[4],b=this.diff_main(f,g,a,s),p=this.diff_main(h,m,a,s);return b.concat([new r.Diff(0,v)],p)}return a&&n.length>100&&t.length>100?this.diff_lineMode_(n,t,s):this.diff_bisect_(n,t,s)},r.prototype.diff_lineMode_=function(n,t,a){var s=this.diff_linesToChars_(n,t);n=s.chars1,t=s.chars2;var i=s.lineArray,o=this.diff_main(n,t,!1,a);this.diff_charsToLines_(o,i),this.diff_cleanupSemantic(o),o.push(new r.Diff(0,""));for(var l=0,u=0,c=0,f="",h="";l=1&&c>=1){o.splice(l-u-c,u+c),l=l-u-c;for(var g=this.diff_main(f,h,!1,a),m=g.length-1;m>=0;m--)o.splice(l,0,g[m]);l+=g.length}c=0,u=0,f="",h=""}l++}return o.pop(),o},r.prototype.diff_bisect_=function(n,t,a){for(var s=n.length,i=t.length,o=Math.ceil((s+i)/2),l=o,u=2*o,c=new Array(u),f=new Array(u),h=0;ha);y++){for(var _=-y+v;_<=y-b;_+=2){for(var N=l+_,C=(D=_==-y||_!=y&&c[N-1]s)b+=2;else if(C>i)v+=2;else if(m&&(k=l+g-_)>=0&&k=(A=s-f[k]))return this.diff_bisectSplit_(n,t,D,C,a)}for(var S=-y+p;S<=y-w;S+=2){for(var A,k=l+S,E=(A=S==-y||S!=y&&f[k-1]s)w+=2;else if(E>i)p+=2;else if(!m&&(N=l+g-S)>=0&&N=(A=s-A))return this.diff_bisectSplit_(n,t,D,C,a)}}}return[new r.Diff(-1,n),new r.Diff(1,t)]},r.prototype.diff_bisectSplit_=function(n,t,a,s,i){var o=n.substring(0,a),l=t.substring(0,s),u=n.substring(a),c=t.substring(s),f=this.diff_main(o,l,!1,i),h=this.diff_main(u,c,!1,i);return f.concat(h)},r.prototype.diff_linesToChars_=function(n,t){var a=[],s={};function i(u){for(var c="",f=0,h=-1,g=a.length;h=a&&t=s&&a-1},_t=function(e,r){var n=this.__data__,t=$e(n,e);return t<0?(++this.size,n.push([e,r])):n[t][1]=r,this};function ie(e){var r=-1,n=e==null?0:e.length;for(this.clear();++ro))return!1;var u=s.get(e),c=s.get(r);if(u&&c)return u==r&&c==e;var f=-1,h=!0,g=2&n?new aa:void 0;for(s.set(e,r),s.set(r,e);++f-1&&e%1==0&&e-1&&e%1==0&&e<=9007199254740991},T={};T["[object Float32Array]"]=T["[object Float64Array]"]=T["[object Int8Array]"]=T["[object Int16Array]"]=T["[object Int32Array]"]=T["[object Uint8Array]"]=T["[object Uint8ClampedArray]"]=T["[object Uint16Array]"]=T["[object Uint32Array]"]=!0,T["[object Arguments]"]=T["[object Array]"]=T["[object ArrayBuffer]"]=T["[object Boolean]"]=T["[object DataView]"]=T["[object Date]"]=T["[object Error]"]=T["[object Function]"]=T["[object Map]"]=T["[object Number]"]=T["[object Object]"]=T["[object RegExp]"]=T["[object Set]"]=T["[object String]"]=T["[object WeakMap]"]=!1;var _a=function(e){return de(e)&&ln(e.length)&&!!T[he(e)]},Na=function(e){return function(r){return e(r)}},Mn=_e((function(e,r){var n=r&&!r.nodeType&&r,t=n&&e&&!e.nodeType&&e,a=t&&t.exports===n&&nr.process,s=(function(){try{var i=t&&t.require&&t.require("util").types;return i||a&&a.binding&&a.binding("util")}catch{}})();e.exports=s})),On=Mn&&Mn.isTypedArray,lr=On?Na(On):_a,ja=Object.prototype.hasOwnProperty,Sa=function(e,r){var n=W(e),t=!n&&ir(e),a=!n&&!t&&Ze(e),s=!n&&!t&&!a&&lr(e),i=n||t||a||s,o=i?va(e.length,String):[],l=o.length;for(var u in e)!ja.call(e,u)||i&&(u=="length"||a&&(u=="offset"||u=="parent")||s&&(u=="buffer"||u=="byteLength"||u=="byteOffset")||or(u,l))||o.push(u);return o},ka=Object.prototype,Ca=function(e){var r=e&&e.constructor;return e===(typeof r=="function"&&r.prototype||ka)},Aa=(function(e,r){return function(n){return e(r(n))}})(Object.keys,Object),Ea=Object.prototype.hasOwnProperty,Da=function(e){if(!Ca(e))return Aa(e);var r=[];for(var n in Object(e))Ea.call(e,n)&&n!="constructor"&&r.push(n);return r},Ta=function(e){return e!=null&&ln(e.length)&&!tr(e)},un=function(e){return Ta(e)?Sa(e):Da(e)},In=function(e){return fa(e,un,ma)},Ma=Object.prototype.hasOwnProperty,Oa=function(e,r,n,t,a,s){var i=1&n,o=In(e),l=o.length;if(l!=In(r).length&&!i)return!1;for(var u=l;u--;){var c=o[u];if(!(i?c in r:Ma.call(r,c)))return!1}var f=s.get(e),h=s.get(r);if(f&&h)return f==r&&h==e;var g=!0;s.set(e,r),s.set(r,e);for(var m=i;++u1)return!1;if(e.length===1){var r=x(e,1)[0];return r.type==="text"&&!r.value}return!0}function hs(e){var r=e.changeKey,n=e.text,t=e.tokens,a=e.renderToken,s=fe(e,fs),i=a?function(o,l){return a(o,Gn,l)}:Gn;return d.jsx("td",O(O({},s),{},{"data-change-key":r,children:t?ds(t)?" ":t.map(i):n||" "}))}var mr=j.memo(hs);function vr(e,r){return function(){var n=r==="old"?hn(e):gn(e);return n===-1?void 0:n}}function br(e,r){return function(n){return e&&n?d.jsx("a",{href:r?"#"+r:void 0,children:n}):n}}function Ie(e,r){return r?function(n){e(),r(n)}:e}function xn(e,r,n,t){return j.useMemo((function(){var a=gr(e,(function(s){return function(i){return s&&s(r,i)}}));return a.onMouseEnter=Ie(n,a.onMouseEnter),a.onMouseLeave=Ie(t,a.onMouseLeave),a}),[e,n,t,r])}function Ln(e,r,n,t,a,s,i,o,l){var u={change:r,side:t,inHoverState:o,renderDefault:vr(r,t),wrapInAnchor:br(a,s)};return d.jsx("td",O(O({className:e},i),{},{"data-change-key":n,children:l(u)}))}function gs(e){var r,n,t,a=e.change,s=e.selected,i=e.tokens,o=e.className,l=e.generateLineClassName,u=e.gutterClassName,c=e.codeClassName,f=e.gutterEvents,h=e.codeEvents,g=e.hideGutter,m=e.gutterAnchor,v=e.generateAnchorID,b=e.renderToken,p=e.renderGutter,w=a.type,y=a.content,_=q(a),N=(r=x(j.useState(!1),2),n=r[0],t=r[1],[n,j.useCallback((function(){return t(!0)}),[]),j.useCallback((function(){return t(!1)}),[])]),C=x(N,3),S=C[0],A=C[1],k=C[2],E=j.useMemo((function(){return{change:a}}),[a]),D=xn(f,E,A,k),B=xn(h,E,A,k),M=v(a),R=l({changes:[a],defaultGenerate:function(){return o}}),G=F("diff-gutter","diff-gutter-".concat(w),u,{"diff-gutter-selected":s}),L=F("diff-code","diff-code-".concat(w),c,{"diff-code-selected":s});return d.jsxs("tr",{id:M,className:F("diff-line",R),children:[!g&&Ln(G,a,_,"old",m,M,D,S,p),!g&&Ln(G,a,_,"new",m,M,D,S,p),d.jsx(mr,O({className:L,changeKey:_,text:y,tokens:i,renderToken:b},B))]})}var ms=j.memo(gs);function vs(e){var r=e.hideGutter,n=e.element;return d.jsx("tr",{className:"diff-widget",children:d.jsx("td",{colSpan:r?1:3,className:"diff-widget-content",children:n})})}var bs=["hideGutter","selectedChanges","tokens","lineClassName"],ps=["hunk","widgets","className"];function ys(e){var r=e.hunk,n=e.widgets,t=e.className,a=fe(e,ps),s=(function(i,o){return i.reduce((function(l,u){var c=q(u);l.push(["change",c,u]);var f=o[c];return f&&l.push(["widget",c,f]),l}),[])})(r.changes,n);return d.jsx("tbody",{className:F("diff-hunk",t),children:s.map((function(i){return(function(o,l){var u=x(o,3),c=u[0],f=u[1],h=u[2],g=l.hideGutter,m=l.selectedChanges,v=l.tokens,b=l.lineClassName,p=fe(l,bs);if(c==="change"){var w=Q(h)?"old":"new",y=Q(h)?hn(h):gn(h),_=v?v[w][y-1]:null;return d.jsx(ms,O({className:b,change:h,hideGutter:g,selected:m.includes(f),tokens:_},p),"change".concat(f))}return c==="widget"?d.jsx(vs,{hideGutter:g,element:h},"widget".concat(f)):null})(i,a)}))})}var pr=0;function Ee(e,r,n,t){var a=j.useCallback((function(){return r(e)}),[e,r]),s=j.useCallback((function(){return r("")}),[r]);return j.useMemo((function(){var i=gr(t,(function(o){return function(l){return o&&o({side:e,change:n},l)}}));return i.onMouseEnter=Ie(a,i.onMouseEnter),i.onMouseLeave=Ie(s,i.onMouseLeave),i}),[n,t,a,e,s])}function Ke(e){var r=e.change,n=e.side,t=e.selected,a=e.tokens,s=e.gutterClassName,i=e.codeClassName,o=e.gutterEvents,l=e.codeEvents,u=e.anchorID,c=e.gutterAnchor,f=e.gutterAnchorTarget,h=e.hideGutter,g=e.hover,m=e.renderToken,v=e.renderGutter;if(!r){var b=F("diff-gutter","diff-gutter-omit",s),p=F("diff-code","diff-code-omit",i);return[!h&&d.jsx("td",{className:b},"gutter"),d.jsx("td",{className:p},"code")]}var w=r.type,y=r.content,_=q(r),N=n===pr?"old":"new",C=O({id:u||void 0,className:F("diff-gutter","diff-gutter-".concat(w),We({"diff-gutter-selected":t},"diff-line-hover-"+N,g),s),children:v({change:r,side:N,inHoverState:g,renderDefault:vr(r,N),wrapInAnchor:br(c,f)})},o),S=F("diff-code","diff-code-".concat(w),We({"diff-code-selected":t},"diff-line-hover-"+N,g),i);return[!h&&d.jsx("td",O(O({},C),{},{"data-change-key":_}),"gutter"),d.jsx(mr,O({className:S,changeKey:_,text:y,tokens:a,renderToken:m},l),"code")]}function ws(e){var r=e.className,n=e.oldChange,t=e.newChange,a=e.oldSelected,s=e.newSelected,i=e.oldTokens,o=e.newTokens,l=e.monotonous,u=e.gutterClassName,c=e.codeClassName,f=e.gutterEvents,h=e.codeEvents,g=e.hideGutter,m=e.generateAnchorID,v=e.generateLineClassName,b=e.gutterAnchor,p=e.renderToken,w=e.renderGutter,y=x(j.useState(""),2),_=y[0],N=y[1],C=Ee("old",N,n,f),S=Ee("new",N,t,f),A=Ee("old",N,n,h),k=Ee("new",N,t,h),E=n&&m(n),D=t&&m(t),B=v({changes:[n,t],defaultGenerate:function(){return r}}),M={monotonous:l,hideGutter:g,gutterClassName:u,codeClassName:c,gutterEvents:f,codeEvents:h,renderToken:p,renderGutter:w},R=O(O({},M),{},{change:n,side:pr,selected:a,tokens:i,gutterEvents:C,codeEvents:A,anchorID:E,gutterAnchor:b,gutterAnchorTarget:E,hover:_==="old"}),G=O(O({},M),{},{change:t,side:1,selected:s,tokens:o,gutterEvents:S,codeEvents:k,anchorID:n===t?null:D,gutterAnchor:b,gutterAnchorTarget:n===t?E:D,hover:_==="new"});if(l)return d.jsx("tr",{className:F("diff-line",B),children:Ke(n?R:G)});var L=(function(X,ee){return X&&!ee?"diff-line-old-only":!X&&ee?"diff-line-new-only":X===ee?"diff-line-normal":"diff-line-compare"})(n,t);return d.jsxs("tr",{className:F("diff-line",L,B),children:[Ke(R),Ke(G)]})}var _s=j.memo(ws);function Ns(e){var r=e.hideGutter,n=e.oldElement,t=e.newElement;return e.monotonous?d.jsx("tr",{className:"diff-widget",children:d.jsx("td",{colSpan:r?1:2,className:"diff-widget-content",children:n||t})}):n===t?d.jsx("tr",{className:"diff-widget",children:d.jsx("td",{colSpan:r?2:4,className:"diff-widget-content",children:n})}):d.jsxs("tr",{className:"diff-widget",children:[d.jsx("td",{colSpan:r?1:2,className:"diff-widget-content",children:n}),d.jsx("td",{colSpan:r?1:2,className:"diff-widget-content",children:t})]})}var js=["selectedChanges","monotonous","hideGutter","tokens","lineClassName"],Ss=["hunk","widgets","className"];function De(e,r){return(e?q(e):"00")+(r?q(r):"00")}function ks(e){var r=e.hunk,n=e.widgets,t=e.className,a=fe(e,Ss),s=(function(i,o){for(var l=function(p){if(!p)return null;var w=q(p);return o[w]||null},u=[],c=0;ct.length?n:t,l=n.length>t.length?t:n,u=o.indexOf(l);if(u!=-1)return i=[new r.Diff(1,o.substring(0,u)),new r.Diff(0,l),new r.Diff(1,o.substring(u+l.length))],n.length>t.length&&(i[0][0]=i[2][0]=-1),i;if(l.length==1)return[new r.Diff(-1,n),new r.Diff(1,t)];var c=this.diff_halfMatch_(n,t);if(c){var f=c[0],h=c[1],g=c[2],m=c[3],v=c[4],b=this.diff_main(f,g,a,s),p=this.diff_main(h,m,a,s);return b.concat([new r.Diff(0,v)],p)}return a&&n.length>100&&t.length>100?this.diff_lineMode_(n,t,s):this.diff_bisect_(n,t,s)},r.prototype.diff_lineMode_=function(n,t,a){var s=this.diff_linesToChars_(n,t);n=s.chars1,t=s.chars2;var i=s.lineArray,o=this.diff_main(n,t,!1,a);this.diff_charsToLines_(o,i),this.diff_cleanupSemantic(o),o.push(new r.Diff(0,""));for(var l=0,u=0,c=0,f="",h="";l=1&&c>=1){o.splice(l-u-c,u+c),l=l-u-c;for(var g=this.diff_main(f,h,!1,a),m=g.length-1;m>=0;m--)o.splice(l,0,g[m]);l+=g.length}c=0,u=0,f="",h=""}l++}return o.pop(),o},r.prototype.diff_bisect_=function(n,t,a){for(var s=n.length,i=t.length,o=Math.ceil((s+i)/2),l=o,u=2*o,c=new Array(u),f=new Array(u),h=0;ha);y++){for(var _=-y+v;_<=y-b;_+=2){for(var N=l+_,C=(D=_==-y||_!=y&&c[N-1]s)b+=2;else if(C>i)v+=2;else if(m&&(k=l+g-_)>=0&&k=(A=s-f[k]))return this.diff_bisectSplit_(n,t,D,C,a)}for(var S=-y+p;S<=y-w;S+=2){for(var A,k=l+S,E=(A=S==-y||S!=y&&f[k-1]s)w+=2;else if(E>i)p+=2;else if(!m&&(N=l+g-S)>=0&&N=(A=s-A))return this.diff_bisectSplit_(n,t,D,C,a)}}}return[new r.Diff(-1,n),new r.Diff(1,t)]},r.prototype.diff_bisectSplit_=function(n,t,a,s,i){var o=n.substring(0,a),l=t.substring(0,s),u=n.substring(a),c=t.substring(s),f=this.diff_main(o,l,!1,i),h=this.diff_main(u,c,!1,i);return f.concat(h)},r.prototype.diff_linesToChars_=function(n,t){var a=[],s={};function i(u){for(var c="",f=0,h=-1,g=a.length;hs?n=n.substring(a-s):at.length?n:t,s=n.length>t.length?t:n;if(a.length<4||2*s.length=v.length?[w,y,_,N,A]:null}var l,u,c,f,h,g=o(a,s,Math.ceil(a.length/4)),m=o(a,s,Math.ceil(a.length/2));return g||m?(l=m?g&&g[4].length>m[4].length?g:m:g,n.length>t.length?(u=l[0],c=l[1],f=l[2],h=l[3]):(f=l[0],h=l[1],u=l[2],c=l[3]),[u,c,f,h,l[4]]):null},r.prototype.diff_cleanupSemantic=function(n){for(var t=!1,a=[],s=0,i=null,o=0,l=0,u=0,c=0,f=0;o0?a[s-1]:-1,l=0,u=0,c=0,f=0,i=null,t=!0)),o++;for(t&&this.diff_cleanupMerge(n),this.diff_cleanupSemanticLossless(n),o=1;o=v?(m>=h.length/2||m>=g.length/2)&&(n.splice(o,0,new r.Diff(0,g.substring(0,m))),n[o-1][1]=h.substring(0,h.length-m),n[o+1][1]=g.substring(m),o++):(v>=h.length/2||v>=g.length/2)&&(n.splice(o,0,new r.Diff(0,h.substring(0,v))),n[o-1][0]=1,n[o-1][1]=g.substring(0,g.length-v),n[o+1][0]=-1,n[o+1][1]=h.substring(v),o++),o++}o++}},r.prototype.diff_cleanupSemanticLossless=function(n){function t(v,b){if(!v||!b)return 6;var p=v.charAt(v.length-1),w=b.charAt(0),y=p.match(r.nonAlphaNumericRegex_),_=w.match(r.nonAlphaNumericRegex_),N=y&&p.match(r.whitespaceRegex_),C=_&&w.match(r.whitespaceRegex_),S=N&&p.match(r.linebreakRegex_),A=C&&w.match(r.linebreakRegex_),k=S&&v.match(r.blanklineEndRegex_),E=A&&b.match(r.blanklineStartRegex_);return k||E?5:S||A?4:y&&!N&&C?3:N||C?2:y||_?1:0}for(var a=1;a=g&&(g=m,c=s,f=i,h=o)}n[a-1][1]!=c&&(c?n[a-1][1]=c:(n.splice(a-1,1),a--),n[a][1]=f,h?n[a+1][1]=h:(n.splice(a+1,1),a--))}a++}},r.nonAlphaNumericRegex_=/[^a-zA-Z0-9]/,r.whitespaceRegex_=/\s/,r.linebreakRegex_=/[\r\n]/,r.blanklineEndRegex_=/\n\r?\n$/,r.blanklineStartRegex_=/^\r?\n\r?\n/,r.prototype.diff_cleanupEfficiency=function(n){for(var t=!1,a=[],s=0,i=null,o=0,l=!1,u=!1,c=!1,f=!1;o0?a[s-1]:-1,c=f=!1),t=!0)),o++;t&&this.diff_cleanupMerge(n)},r.prototype.diff_cleanupMerge=function(n){n.push(new r.Diff(0,""));for(var t,a=0,s=0,i=0,o="",l="";a1?(s!==0&&i!==0&&((t=this.diff_commonPrefix(l,o))!==0&&(a-s-i>0&&n[a-s-i-1][0]==0?n[a-s-i-1][1]+=l.substring(0,t):(n.splice(0,0,new r.Diff(0,l.substring(0,t))),a++),l=l.substring(t),o=o.substring(t)),(t=this.diff_commonSuffix(l,o))!==0&&(n[a][1]=l.substring(l.length-t)+n[a][1],l=l.substring(0,l.length-t),o=o.substring(0,o.length-t))),a-=s+i,n.splice(a,s+i),o.length&&(n.splice(a,0,new r.Diff(-1,o)),a++),l.length&&(n.splice(a,0,new r.Diff(1,l)),a++),a++):a!==0&&n[a-1][0]==0?(n[a-1][1]+=n[a][1],n.splice(a,1)):a++,i=0,s=0,o="",l=""}n[n.length-1][1]===""&&n.pop();var u=!1;for(a=1;at));a++)o=s,l=i;return n.length!=a&&n[a][0]===-1?l:l+(t-o)},r.prototype.diff_prettyHtml=function(n){for(var t=[],a=/&/g,s=//g,o=/\n/g,l=0;l");switch(u){case 1:t[l]=''+c+"";break;case-1:t[l]=''+c+"";break;case 0:t[l]=""+c+""}}return t.join("")},r.prototype.diff_text1=function(n){for(var t=[],a=0;athis.Match_MaxBits)throw new Error("Pattern too long for this browser.");var s=this.match_alphabet_(t),i=this;function o(C,S){var A=C/t.length,k=Math.abs(a-S);return i.Match_Distance?A+k/i.Match_Distance:k?1:A}var l=this.Match_Threshold,u=n.indexOf(t,a);u!=-1&&(l=Math.min(o(0,u),l),(u=n.lastIndexOf(t,a+t.length))!=-1&&(l=Math.min(o(0,u),l)));var c,f,h=1<=b;y--){var _=s[n.charAt(y-1)];if(w[y]=v===0?(w[y+1]<<1|1)&_:(w[y+1]<<1|1)&_|(g[y+1]|g[y])<<1|1|g[y+1],w[y]&h){var N=o(v,y-1);if(N<=l){if(l=N,!((u=y-1)>a))break;b=Math.max(1,2*a-u)}}}if(o(v+1,a)>l)break;g=w}return u},r.prototype.match_alphabet_=function(n){for(var t={},a=0;a2&&(this.diff_cleanupSemantic(i),this.diff_cleanupEfficiency(i));else if(n&&typeof n=="object"&&t===void 0&&a===void 0)i=n,s=this.diff_text1(i);else if(typeof n=="string"&&t&&typeof t=="object"&&a===void 0)s=n,i=t;else{if(typeof n!="string"||typeof t!="string"||!a||typeof a!="object")throw new Error("Unknown call format to patch_make.");s=n,i=a}if(i.length===0)return[];for(var o=[],l=new r.patch_obj,u=0,c=0,f=0,h=s,g=s,m=0;m=2*this.Patch_Margin&&u&&(this.patch_addContext_(l,h),o.push(l),l=new r.patch_obj,u=0,h=g,c=f)}v!==1&&(c+=b.length),v!==-1&&(f+=b.length)}return u&&(this.patch_addContext_(l,h),o.push(l)),o},r.prototype.patch_deepCopy=function(n){for(var t=[],a=0;athis.Match_MaxBits?(l=this.match_main(t,f.substring(0,this.Match_MaxBits),c))!=-1&&((h=this.match_main(t,f.substring(f.length-this.Match_MaxBits),c+f.length-this.Match_MaxBits))==-1||l>=h)&&(l=-1):l=this.match_main(t,f,c),l==-1)i[o]=!1,s-=n[o].length2-n[o].length1;else if(i[o]=!0,s=l-c,f==(u=h==-1?t.substring(l,l+f.length):t.substring(l,h+this.Match_MaxBits)))t=t.substring(0,l)+this.diff_text2(n[o].diffs)+t.substring(l+f.length);else{var g=this.diff_main(f,u,!1);if(f.length>this.Match_MaxBits&&this.diff_levenshtein(g)/f.length>this.Patch_DeleteThreshold)i[o]=!1;else{this.diff_cleanupSemanticLossless(g);for(var m,v=0,b=0;bo[0][1].length){var l=t-o[0][1].length;o[0][1]=a.substring(o[0][1].length)+o[0][1],i.start1-=l,i.start2-=l,i.length1+=l,i.length2+=l}return(o=(i=n[n.length-1]).diffs).length==0||o[o.length-1][0]!=0?(o.push(new r.Diff(0,a)),i.length1+=t,i.length2+=t):t>o[o.length-1][1].length&&(l=t-o[o.length-1][1].length,o[o.length-1][1]+=a.substring(0,l),i.length1+=l,i.length2+=l),a},r.prototype.patch_splitMax=function(n){for(var t=this.Match_MaxBits,a=0;a2*t?(u.length1+=h.length,i+=h.length,c=!1,u.diffs.push(new r.Diff(f,h)),s.diffs.shift()):(h=h.substring(0,t-u.length1-this.Patch_Margin),u.length1+=h.length,i+=h.length,f===0?(u.length2+=h.length,o+=h.length):c=!1,u.diffs.push(new r.Diff(f,h)),h==s.diffs[0][1]?s.diffs.shift():s.diffs[0][1]=s.diffs[0][1].substring(h.length))}l=(l=this.diff_text2(u.diffs)).substring(l.length-this.Patch_Margin);var g=this.diff_text1(s.diffs).substring(0,this.Patch_Margin);g!==""&&(u.length1+=g.length,u.length2+=g.length,u.diffs.length!==0&&u.diffs[u.diffs.length-1][0]===0?u.diffs[u.diffs.length-1][1]+=g:u.diffs.push(new r.Diff(0,g))),c||n.splice(++a,0,u)}}},r.prototype.patch_toText=function(n){for(var t=[],a=0;aRs(e.patch),[e.patch]);return d.jsxs("section",{children:[d.jsxs("div",{className:"flex items-baseline justify-between gap-4 flex-wrap",children:[d.jsx("h3",{className:"text-body font-semibold text-fg",children:"Local Changes"}),d.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:[e.changedFiles.length," changed file",e.changedFiles.length===1?"":"s"]})]}),e.rootPath.kind==="known"&&d.jsx("p",{className:"mt-1 text-label text-fg-faint break-all",children:e.rootPath.path}),d.jsx("p",{className:"mt-2 text-label uppercase tracking-wider text-fg-muted",children:$s(e.comparison)}),r.length===0?d.jsx("p",{className:"mt-5 text-body text-fg-muted italic",children:"No renderable patch in this work tree."}):d.jsx("div",{className:"formula-run-diff-view mt-5 space-y-3",children:r.map(n=>d.jsx(Is,{file:n},`${n.oldRevision}:${n.newRevision}:${wr(n)}`))}),e.truncated&&d.jsx("p",{className:"mt-3 text-label uppercase tracking-wider text-fg-faint",children:"Diff truncated at the backend output cap."})]})}function Is({file:e}){const r=Fs(e.hunks);return d.jsxs("details",{className:"border-y border-rule py-2",open:!0,children:[d.jsxs("summary",{className:"cursor-pointer list-none text-label uppercase tracking-wider text-fg-muted",children:[d.jsx("span",{className:"font-medium normal-case tracking-normal text-body text-fg",children:wr(e)}),d.jsxs("span",{className:"ml-3 tnum text-fg-faint",children:["+",r.additions," -",r.deletions]})]}),e.hunks.length===0||e.isBinary?d.jsx("p",{className:"mt-3 text-body text-fg-muted italic",children:"No textual hunks."}):d.jsx("div",{className:"mt-3 overflow-auto",children:d.jsx(Ts,{viewType:"unified",diffType:e.type,hunks:e.hunks,renderGutter:Ps,children:n=>n.map(t=>d.jsx(yr,{hunk:t},Bs(t)))})})]})}function Rs(e){if(e.trim().length===0)return[];try{return dt(e,{nearbySequences:"zip"})}catch{return[]}}function Ps({change:e,side:r,renderDefault:n}){return e.type==="insert"&&r==="old"?d.jsx("span",{className:"diff-gutter-sign","aria-hidden":!0,children:"+"}):e.type==="delete"&&r==="new"?d.jsx("span",{className:"diff-gutter-sign","aria-hidden":!0,children:"-"}):n()}function $s(e){return e.kind==="upstream"?`Compared with ${e.ref} at ${e.mergeBase.slice(0,12)}.`:e.kind==="head"&&e.reason==="no_upstream"?"No upstream branch is configured; showing changes relative to HEAD plus untracked files.":e.kind==="head"?"Upstream comparison failed; showing changes relative to HEAD plus untracked files.":"Comparison unavailable."}function wr(e){const r=zn(e.oldPath),n=zn(e.newPath);return e.type==="delete"?r:e.type==="rename"&&r!==n?`${r} -> ${n}`:n||r}function zn(e){return e.replace(/^[ab]\//,"")}function Fs(e){let r=0,n=0;for(const t of e)for(const a of t.changes)a.type==="insert"&&(r+=1),a.type==="delete"&&(n+=1);return{additions:r,deletions:n}}function Bs(e){return`${e.oldStart}:${e.newStart}:${e.content}`}function Gs({node:e,visible:r}){const n=j.useMemo(()=>e?.executionInstances.sort(Nr)??[],[e]),t=j.useMemo(()=>zs(e?.visibleExecutionInstanceId,n),[e?.visibleExecutionInstanceId,n]),[a,s]=j.useState(null);if(j.useEffect(()=>{s(t?z(t):null)},[e?.id,t]),!e)return d.jsx("p",{className:"text-body text-fg-muted italic",children:"Select a node to inspect its session."});if(n.length===0)return d.jsx("p",{className:"text-body text-fg-muted italic",children:Kn(e)});const i=n.find(c=>z(c)===a)??t??n[0],o=i?we(i):"base",l=Ks(n),u=n.filter(c=>we(c)===o);return i?d.jsxs("section",{children:[d.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[d.jsx("h3",{className:"text-body font-semibold text-fg",children:e.title}),(e.historicalOnly||i?.historical)&&d.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint",children:e.historicalOnly?"historical-only":"historical"})]}),l.length>1&&d.jsxs("div",{className:"mt-3 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-label",role:"radiogroup","aria-label":"Iterations",children:[d.jsx("span",{className:"uppercase tracking-wider text-fg-faint",children:"Iterations"}),l.map(c=>{const f=c.instances.at(-1);if(!f)return null;const h=c.iteration==="base"?"Base":`Iteration ${c.iteration}`,g=c.iteration===o;return d.jsxs("span",{className:"flex items-baseline gap-1",children:[d.jsx("span",{"aria-hidden":!0,className:"text-fg-faint",children:"·"}),d.jsx("button",{type:"button",role:"radio","aria-checked":g,className:`focus-mark rounded-sm px-0.5 uppercase tracking-wider ${g?"text-fg font-semibold underline decoration-fg underline-offset-4":"text-fg-muted hover:text-fg"}`,onClick:()=>s(z(f)),children:h})]},h)})]}),u.length>1&&d.jsxs("div",{className:"mt-2 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-label",role:"radiogroup","aria-label":"Attempts",children:[d.jsx("span",{className:"uppercase tracking-wider text-fg-faint",children:"Attempts"}),u.map(c=>d.jsxs("span",{className:"flex items-baseline gap-1",children:[d.jsx("span",{"aria-hidden":!0,className:"text-fg-faint",children:"·"}),d.jsxs("button",{type:"button",role:"radio","aria-checked":z(c)===z(i),className:`focus-mark rounded-sm px-0.5 uppercase tracking-wider ${z(c)===z(i)?"text-fg font-semibold underline decoration-fg underline-offset-4":"text-fg-muted hover:text-fg"}`,onClick:()=>s(z(c)),children:["Attempt ",en(c)]})]},z(c)))]}),d.jsxs("dl",{className:"mt-4 grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-1 text-label",children:[d.jsx("dt",{className:"uppercase tracking-wider text-fg-faint",children:"Execution instance"}),d.jsx("dd",{className:"break-all text-fg-muted tnum",children:i.id}),d.jsx("dt",{className:"uppercase tracking-wider text-fg-faint",children:"Bead"}),d.jsx("dd",{className:"break-all text-fg-muted tnum",children:i.beadId})]}),d.jsx(xs,{instance:i,visible:r})]}):d.jsx("p",{className:"text-body text-fg-muted italic",children:Kn(e)})}function xs({instance:e,visible:r}){const n=e.session.kind==="attached"?e.session:null,t=n?.link.sessionId??null,a=r&&!!n?.streamable,s=Hr(t,a);if(n===null)return d.jsx("p",{className:"mt-5 text-body text-fg-muted italic",children:Us(e)});const i=Ls(s.stream),o=s.status==="loading",l=s.status==="ready"?s.result:null,u=s.status==="failed"?s.error:null,c=s.status==="ready"&&s.stream.status==="degraded"?s.stream.error:null;return d.jsxs("div",{className:"mt-5 space-y-4",children:[n?.streamable&&d.jsx("div",{className:"flex justify-end",children:d.jsx(Tr,{tone:i.tone,label:i.label,title:`Session stream: ${s.stream.status}`,className:"text-label uppercase tracking-wider"})}),c!==null&&d.jsx("p",{className:"text-accent",role:"alert",children:c}),d.jsx(Wr,{loading:o,error:u,result:l})]})}function Ls(e){switch(e.status){case"open":return{tone:"ok",label:"live"};case"connecting":return{tone:"warn",label:"connecting"};case"closed":return{tone:"stuck",label:"offline"};case"degraded":return{tone:"warn",label:"degraded"};case"idle":return{tone:"neutral",label:"snapshot"}}}function Kn(e){const r=e.executionInstances.filter(t=>t.session.kind==="none");return r.some(t=>t.currentIteration&&t.session.kind==="none"&&t.session.reason==="session_unresolved"&&_r(t.status))?"Session unresolved for the current running node.":r.some(t=>t.session.kind==="none"&&t.session.reason==="session_unresolved")?"Session unresolved for this node.":"This node has not started a session yet."}function Us(e){return e.session.kind==="attached"?"":e.currentIteration&&e.session.reason==="session_unresolved"&&_r(e.status)?"Session unresolved for the current running node.":e.session.reason==="session_unresolved"?"Session unresolved for this node.":"This node has not started a session yet."}function _r(e){return e==="active"||e==="running"}function zs(e,r){return(e?r.find(t=>z(t)===e):void 0)??r.at(-1)}function Ks(e){const r=new Map;for(const n of e){const t=we(n);r.set(t,[...r.get(t)??[],n])}return[...r.entries()].map(([n,t])=>({iteration:n,instances:t.sort(Nr)})).sort((n,t)=>Re(n.iteration)-Re(t.iteration))}function Nr(e,r){return Re(we(e))-Re(we(r))||en(e)-en(r)||e.id.localeCompare(r.id)}function z(e){return e.id}function we(e){return e.iteration.kind==="loop"?e.iteration.value:"base"}function Re(e){return e==="base"?0:e}function en(e){return e.attempt.kind==="attempt"?e.attempt.value:1}function Hs({tab:e,diff:r,selectedNode:n}){return e==="session"?d.jsx(Gs,{node:n,visible:!0}):d.jsx(Ws,{diff:r})}function Ws({diff:e}){switch(e.kind){case"idle":return d.jsx("p",{className:"text-body text-fg-muted italic",children:"Local changes are not loaded for this run."});case"loading":return d.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading local changes."});case"failed":return d.jsx("p",{className:"text-body text-accent",role:"alert",children:e.error});case"ready":return d.jsxs(d.Fragment,{children:[e.refreshState.kind==="failed"&&d.jsx("p",{className:"mb-4 text-body text-accent",role:"alert",children:e.refreshState.error}),e.refreshState.kind==="refreshing"&&d.jsx("p",{className:"mb-4 text-label uppercase tracking-wider text-fg-faint",role:"status",children:"Refreshing local changes"}),d.jsx(Ms,{diff:e.diff})]})}}function Vs({diff:e,selectedNode:r,activeTab:n,onActiveTabChange:t}){const[a,s]=j.useState("diff"),i=n!==void 0&&t!==void 0,o=i?n:a,l=c=>{i?t(c):s(c)},u=`run-evidence-tab-${o}`;return d.jsxs("section",{"aria-label":"Run evidence",children:[d.jsxs("div",{className:"flex items-baseline gap-2 text-label",role:"tablist","aria-label":"Run evidence views",children:[d.jsx(Hn,{id:"run-evidence-tab-diff",controls:"run-evidence-panel",active:o==="diff",onClick:()=>l("diff"),children:"Diff"}),d.jsx("span",{"aria-hidden":!0,className:"text-fg-faint",children:"·"}),d.jsx(Hn,{id:"run-evidence-tab-session",controls:"run-evidence-panel",active:o==="session",onClick:()=>l("session"),children:"Session"})]}),d.jsx("div",{id:"run-evidence-panel",role:"tabpanel","aria-labelledby":u,className:"pt-5",children:d.jsx(Hs,{tab:o,diff:e,selectedNode:r})})]})}function Hn({id:e,controls:r,active:n,disabled:t=!1,onClick:a,children:s}){return d.jsx("button",{id:e,type:"button",role:"tab","aria-selected":n,"aria-controls":r,"aria-disabled":t||void 0,disabled:t,className:`focus-mark rounded-sm px-0.5 uppercase tracking-wider ${t?"cursor-not-allowed text-fg-faint":n?"text-fg font-semibold underline decoration-fg underline-offset-4":"text-fg-muted hover:text-fg"}`,onClick:a,children:s})}function Xs(e,r){const n=e.runIds.size===0||e.runIds.has(r.runId),t=e.rootBeadIds.size===0||e.rootBeadIds.has(r.rootBeadId);return n&&t}function Zs(e){const r={runIds:new Set,rootBeadIds:new Set};return J(e,r),J($(e.run),r),J($(e.payload),r),J($($(e.payload)?.run),r),J($(e.bead),r),J($($(e.payload)?.bead),r),J($(e.root),r),J($($(e.payload)?.root),r),nn($(e.metadata),r),nn($($(e.payload)?.metadata),r),r}function J(e,r){e&&(H(r.runIds,e.run_id),H(r.runIds,e.workflow_id),H(r.rootBeadIds,e.root_bead_id),nn($(e.metadata),r))}function nn(e,r){e&&(H(r.runIds,e["gc.run_id"]),H(r.runIds,e["gc.workflow_id"]),H(r.runIds,e.run_id),H(r.runIds,e.workflow_id),H(r.rootBeadIds,e["gc.root_bead_id"]),H(r.rootBeadIds,e.root_bead_id))}function H(e,r){if(typeof r!="string")return;const n=r.trim();n&&e.add(n)}function $(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)?e:void 0}function Ys(e,r,n){const[t,a]=j.useState({nodeId:null,routeKey:"",source:"route"});j.useEffect(()=>{if(!e)return;const u=Js(e,r);a(c=>c.routeKey===n&&(c.source==="user"||c.nodeId===u)?c:{nodeId:u,routeKey:n,source:"route"})},[e,n,r]);const s=j.useCallback(()=>{a(u=>({nodeId:null,routeKey:u.routeKey,source:"user"}))},[]);j.useEffect(()=>{const u=c=>{c.key==="Escape"&&s()};return window.addEventListener("keydown",u),()=>window.removeEventListener("keydown",u)},[s]);const i=j.useCallback(u=>{a(c=>({nodeId:c.nodeId===u?null:u,routeKey:n,source:"user"}))},[n]),o=t.nodeId,l=j.useMemo(()=>e?.nodes.find(u=>u.id===o)??null,[e,o]);return{selectedNodeId:o,selectedNode:l,toggleNode:i,clearSelection:s}}function Js(e,r){return r&&e.nodes.some(n=>n.id===r)?r:null}const Wn=[600,1200,2400],Qs=5e3,qs=18e4;async function ei(e,r){let n=0;for(let t=0;;t+=1)try{return await Pe.runDetail(e)}catch(a){const s=ni(a,t,n);if(s===void 0||r?.keepPolling?.()===!1||(jr(a)&&r?.onWarming?.({reason:a.reason}),n+=s,await ti(s),r?.keepPolling?.()===!1))throw a}}function ni(e,r,n){if(jr(e)){const t=Wn[r]??Qs;return n+t<=qs?t:void 0}return ri(e)?Wn[r]:void 0}function jr(e){return e instanceof Oe&&e.status===503}function ri(e){return e instanceof Oe?e.status>=500:e instanceof TypeError}function ti(e){return new Promise(r=>setTimeout(r,e))}function ai(e,r,n,t,a){const[s,i]=j.useState("unavailable"),o=j.useRef(n);o.current=n;const l=j.useRef(!1),u=Sr(e,t,a);return j.useEffect(()=>{if(l.current=!1,!e||!r||typeof EventSource>"u"){i("unavailable");return}let c=!1;i("connecting");const f=new EventSource(Pe.runDetailStreamUrl(e),{withCredentials:!0});f.onopen=()=>{c||i("open")};const h=g=>{if(c)return;const m=si(g.data,e,l);m!==null&&(Mr(u,{kind:"loaded",detail:m}),o.current?.(m,u),i("open"))};return f.addEventListener("detail",h),f.onerror=()=>{c||i(f.readyState===EventSource.CLOSED?"closed":"connecting")},()=>{c=!0,f.close()}},[e,r,u]),s}function si(e,r,n){let t;try{t=JSON.parse(e)}catch(a){return Vn(r,n,a),null}try{return Or(t,Pe.runDetailStreamUrl(r))}catch(a){return Vn(r,n,a),null}}function Vn(e,r,n){r.current||(r.current=!0,rn({component:"formula-run-detail-stream",operation:"parse stream frame",message:`${e}: ${tn(n)}`}))}function ii(e,r,n){const t=Sr(e,r,n),[a,s]=j.useState(null),i=j.useRef(0);j.useEffect(()=>()=>{i.current+=1},[]);const{data:o,loading:l,error:u,refresh:c}=Yn(t,()=>{const _=++i.current,N=()=>i.current===_;return oi(e,{onWarming:S=>{N()&&s(S)},keepPolling:N}).finally(()=>{N()&&s(null)})},{onError:_=>{e!==void 0&&ci("load detail",e,_)}}),[f,h]=j.useState(null),g=j.useCallback((_,N)=>h({key:N,detail:_}),[]),m=e!==void 0&&o?.kind!=="unsupported"&&o?.kind!=="not_found",v=ai(e,m,g,r,n),b=f?.key===t?f.detail:null,p=v==="open"||v==="connecting",w=j.useCallback(async()=>{h(null),await c()},[c]);if(e===void 0)return{kind:"idle",refresh:li,streamActive:p};const y=b??(o?.kind==="loaded"?o.detail:null);return y!==null?{kind:"ready",detail:y,refresh:w,refreshState:ui(l,u),streamActive:p}:o?.kind==="unsupported"?{kind:"unsupported",refresh:w,streamActive:p}:o?.kind==="not_found"?{kind:"not_found",refresh:w,streamActive:p}:u!==null?{kind:"failed",error:u,refresh:w,streamActive:p}:{kind:"loading",warming:a,refresh:w,streamActive:p}}async function oi(e,r){if(!e)return{kind:"unrequested"};try{return{kind:"loaded",detail:await ei(e,r)}}catch(n){if(n instanceof Oe&&n.status===422&&n.reason==="not_run_view")return{kind:"unsupported"};if(n instanceof Oe&&n.status===404)return{kind:"not_found"};throw n}}async function li(){}function ui(e,r){return r!==null?{kind:"failed",error:r}:e?{kind:"refreshing"}:{kind:"idle"}}function ci(e,r,n){rn({component:"formula-run-detail",operation:e,message:`${r}: ${tn(n)}`})}function Sr(e,r,n){return["formula-run",e??"missing",r??"default",n??"default"].map(encodeURIComponent).join(":")}function fi(e,r,n,t){const a=gi(e,r,n,t),{data:s,loading:i,error:o,refresh:l,cheapRefresh:u}=Yn(a,()=>He(e,r,n,t),{refreshFetcher:()=>He(e,r,n,t,!0),sseRefreshFetcher:()=>He(e,r,n,t,!1),onError:c=>{e!==void 0&&hi("load diff",e,c)}});return e===void 0||r===void 0?{kind:"idle",refresh:Xn,cheapRefresh:Xn}:s?.kind==="loaded"?{kind:"ready",diff:s.diff,refresh:l,cheapRefresh:u,refreshState:di(i,o)}:o!==null?{kind:"failed",error:o,refresh:l,cheapRefresh:u}:{kind:"loading",refresh:l,cheapRefresh:u}}async function He(e,r,n,t,a){if(!e||r===void 0)return{kind:"unrequested"};const s={};return n!==void 0&&(s.scopeKind=n),t!==void 0&&(s.scopeRef=t),a&&(s.refresh=!0),{kind:"loaded",diff:await Pe.runDiff(e,{executionPath:r},s)}}async function Xn(){}function di(e,r){return r!==null?{kind:"failed",error:r}:e?{kind:"refreshing"}:{kind:"idle"}}function hi(e,r,n){rn({component:"formula-run-detail",operation:e,message:`${r}: ${tn(n)}`})}function gi(e,r,n,t){return["formula-run-diff",e??"missing",mi(r),n??"default",t??"default"].join(":")}function mi(e){return e===void 0?"path:missing":e.kind==="known"?`path:${e.path}`:`path:${e.reason}`}const vi=[wn.bead,wn.session],bi=[];function Li(){const{runId:e}=Ir(),[r]=Rr(),n=Ti(r),t=n.ok?n.scope:void 0,a=n.ok?null:n.error,s=r.get("node"),i=[e??"",t?.scopeKind??"",t?.scopeRef??"",s??""].join("\0"),o=ii(a?void 0:e,t?.scopeKind,t?.scopeRef),l=o.kind==="ready"?o:null,u=l?.detail??null,c=o.kind==="unsupported",f=o.kind==="not_found",h=fi(a||u===null?void 0:e,u?.executionPath,t?.scopeKind,t?.scopeRef),g=o.kind==="loading",m=l!==null&&l.refreshState.kind==="refreshing"||h.kind==="ready"&&h.refreshState.kind==="refreshing",v=u!==null&&h.kind==="loading",b=g||m||v,p=o.kind==="failed"?o.error:l!==null&&l.refreshState.kind==="failed"?l.refreshState.error:null,[w,y]=j.useState("diff"),_=w==="diff",N=o.streamActive;Pr(a?bi:vi,()=>{pi(N,_,o.refresh,h.cheapRefresh)},{matches:I=>{const Y=Zs(I);return u===null?e!==void 0&&(Y.runIds.size===0||Y.runIds.has(e)):u.progress.terminal&&wi(Y)?!1:Xs(Y,{runId:u.runId,rootBeadId:u.rootBeadId})}});const C=j.useRef(h.cheapRefresh);C.current=h.cheapRefresh;const S=j.useRef(_);j.useEffect(()=>{const I=S.current;S.current=_,_&&!I&&C.current()},[_]);const A=j.useCallback(I=>y(I),[]),k=a??p,E=o.kind==="loading"&&o.warming?.reason==="unknown_run",{selectedNodeId:D,selectedNode:B,toggleNode:M}=Ys(u,s,i),R=Ur(u?.rootBeadId??null),[G,L]=j.useState(null),X=$r(),ee=xr(),[Z]=j.useState(()=>Fr(`runs:summary:${ee??"no-city"}`)),ge=j.useMemo(()=>{if(!e)return null;const I=Z&&Z.status!=="error"?Z.data:null;return I==null?null:[...I.lanes,...I.blockedLanes].find(Y=>Y.id===e)??null},[Z,e]),U=u?`${u.progress.visibleNodeCount} nodes. ${Mi(u.progress)}. Local changes are shown for the run execution folder.`:g&&!a||c||f?void 0:"Formula run unavailable.";return d.jsxs("section",{children:[d.jsx(Lr,{title:u?.title??"Formula Run",synopsis:U,meta:d.jsxs(d.Fragment,{children:[d.jsx(Br,{to:"/runs",className:"focus-mark text-label uppercase tracking-wider text-fg-muted hover:text-fg",children:"Runs"}),k&&u&&d.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:k}),u&&d.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:ji(u)}),d.jsx(Gr,{size:"sm",onClick:()=>{kr(o.refresh,h.refresh)},disabled:b||!!a,children:m?"Refreshing":"Refresh"})]})}),b&&!a&&!u?ge?d.jsxs(d.Fragment,{children:[d.jsx(_n,{stages:ge.stages,label:ge.title}),d.jsx("p",{className:"text-body text-fg-muted italic mt-8",children:"Loading run detail."})]}):E?d.jsx("p",{className:"text-body text-fg-muted italic",role:"status",children:"This run may still be being recorded — new work can take a couple of minutes to appear — or it may no longer exist."}):d.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading formula run."}):c?d.jsx("p",{className:"text-body text-fg-muted",role:"status",children:"Detailed step view isn’t available for this run (v1/wisp runs are list-only) — this run appears in the run list only."}):f?d.jsx("p",{className:"text-body text-fg-muted",role:"status",children:"This run’s detail snapshot was not found. It may be a v1/wisp run, a completed run whose snapshot wasn’t retained, or no longer available."}):k&&!u?d.jsx("p",{className:"text-body text-accent",role:"alert",children:k}):l?d.jsxs(d.Fragment,{children:[d.jsx(_i,{detail:l.detail}),d.jsx(_n,{stages:l.detail.stages,label:l.detail.title}),d.jsx(ki,{detail:l.detail}),d.jsxs("div",{className:"mt-8 grid gap-10 lg:grid-cols-[minmax(0,0.95fr)_minmax(22rem,1.05fr)]",children:[d.jsx(et,{detail:l.detail,selectedNodeId:D,onToggleNode:M}),d.jsx(Vs,{diff:h,selectedNode:B,activeTab:w,onActiveTabChange:A})]}),d.jsx(zr,{view:R.view,loading:R.loading,error:R.error,now:X,onOpenBead:L}),d.jsx(Kr,{open:G!==null,onClose:()=>L(null),beadId:G,onOpenBead:L})]}):null]})}async function kr(e,r){await Promise.all([e(),r()])}function pi(e,r,n,t){const a=r?t:yi;return e?a():kr(n,a)}async function yi(){}function wi(e){return e.runIds.size===0&&e.rootBeadIds.size===0}function _i({detail:e}){const r=Si(e.formulaDetail);return d.jsxs("dl",{className:"grid gap-x-8 gap-y-3 sm:grid-cols-2 lg:grid-cols-4",children:[d.jsx(Ni,{formula:e.formula}),r!==null&&d.jsx(ce,{label:"Formula Detail",value:r}),d.jsx(ce,{label:"Root",value:e.rootBeadId}),d.jsx(ce,{label:"Scope",value:`${e.scopeKind}:${e.scopeRef}`}),d.jsx(ce,{label:"Store",value:e.resolvedRootStore||e.rootStoreRef||"unknown"})]})}function ce({label:e,value:r}){return d.jsxs("div",{children:[d.jsx("dt",{className:"text-label uppercase tracking-wider text-fg-faint",children:e}),d.jsx("dd",{className:"text-body text-fg break-all tnum",children:r})]})}const Zn="name inferred from bead title — supervisor did not set gc.formula on this graph.v2 root";function Ni({formula:e}){if(e.kind!=="known")return d.jsx(ce,{label:"Formula",value:"metadata missing"});switch(e.source){case"metadata":return d.jsx(ce,{label:"Formula",value:e.name});case"title_fallback":return d.jsxs("div",{children:[d.jsx("dt",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Formula"}),d.jsxs("dd",{className:"text-body text-warn break-all tnum",title:Zn,"aria-label":`${e.name} (${Zn})`,children:[e.name,d.jsx("span",{className:"ml-2 text-label uppercase tracking-wider text-warn",children:"inferred from bead title"})]})]});default:return e.source}}function ji(e){return e.snapshotEventSeq.kind==="known"?`v${e.snapshotVersion} · seq ${e.snapshotEventSeq.seq}`:`v${e.snapshotVersion}`}function Si(e){return e.kind==="available"?`available for ${e.target}`:e.reason==="missing_formula_metadata"?null:e.reason==="missing_run_target"?`missing run target for ${e.name}`:`${e.failure} for ${e.target}`}function ki({detail:e}){if(e.completeness.kind!=="partial")return null;const r=Ci(e.completeness.reasons);return r.length===0?null:d.jsxs("p",{className:"mt-5 text-label uppercase tracking-wider text-warn",role:"status",children:["Partial run data: ",Ei(r),"."]})}function Ci(e){return e.filter(r=>!Ai(r))}function Ai(e){switch(e){case"formula_detail_missing_formula_metadata":case"formula_detail_missing_run_target":case"formula_detail_fetch_failed":return!0;case"supervisor_snapshot_partial":case"runtime_bead_read_failed":case"session_list_failed":return!1}}function Ei(e){return e.map(Di).join(", ")}function Di(e){switch(e){case"supervisor_snapshot_partial":return"supervisor snapshot is partial";case"runtime_bead_read_failed":return"runtime bead refresh failed";case"session_list_failed":return"session list failed";case"formula_detail_missing_formula_metadata":return"formula metadata is missing";case"formula_detail_missing_run_target":return"formula run target is missing";case"formula_detail_fetch_failed":return"formula detail fetch failed"}}function Ti(e){const r=e.getAll("scope_kind"),n=e.getAll("scope_ref");if(r.length>1||n.length>1)return{ok:!1,error:"Invalid run scope query."};const t=r[0],a=n[0];return t===void 0&&a===void 0?{ok:!0}:t===void 0||a===void 0?{ok:!1,error:"Invalid run scope query."}:t!=="city"&&t!=="rig"?{ok:!1,error:"Invalid run scope query."}:Vr.test(a)?{ok:!0,scope:{scopeKind:t,scopeRef:a}}:{ok:!1,error:"Invalid run scope query."}}function Mi(e){const r=[re(e,["active","running"],"running"),re(e,["completed","done"],"done"),re(e,"ready","ready"),re(e,"blocked","blocked"),re(e,"failed","failed"),re(e,"skipped","skipped"),re(e,"pending","pending")].filter(n=>n!==null);return r.length>0?r.join(", "):"No node status yet"}function re(e,r,n){const a=(typeof r=="string"?[r]:r).reduce((s,i)=>s+(e.statusCounts[i]??0),0);return a>0?`${a} ${n}`:null}export{Li as FormulaRunDetailPage,pi as runDetailNudgeRefresh}; +`}return t.join("").replace(/%20/g," ")},e.exports=r,e.exports.diff_match_patch=r,e.exports.DIFF_DELETE=-1,e.exports.DIFF_INSERT=1,e.exports.DIFF_EQUAL=0}));mn.DIFF_EQUAL;mn.DIFF_DELETE;mn.DIFF_INSERT;function Ms({diff:e}){switch(e.kind){case"path_unknown":return d.jsxs("div",{className:"space-y-1",children:[d.jsx("p",{className:"text-body text-fg-muted italic",children:"No diff available for this run."}),d.jsx("p",{className:"text-label text-fg-faint",children:"The run did not record a work_dir, so there is no work tree to compare."})]});case"not_git":return d.jsx("p",{className:"text-body text-fg-muted italic",children:"Execution folder is not a git work tree."});case"error":return d.jsx("p",{className:"text-body text-accent",role:"alert",children:e.error});case"ok":return d.jsx(Os,{diff:e})}}function Os({diff:e}){const r=j.useMemo(()=>Rs(e.patch),[e.patch]);return d.jsxs("section",{children:[d.jsxs("div",{className:"flex items-baseline justify-between gap-4 flex-wrap",children:[d.jsx("h3",{className:"text-body font-semibold text-fg",children:"Local Changes"}),d.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:[e.changedFiles.length," changed file",e.changedFiles.length===1?"":"s"]})]}),e.rootPath.kind==="known"&&d.jsx("p",{className:"mt-1 text-label text-fg-faint break-all",children:e.rootPath.path}),d.jsx("p",{className:"mt-2 text-label uppercase tracking-wider text-fg-muted",children:$s(e.comparison)}),r.length===0?d.jsx("p",{className:"mt-5 text-body text-fg-muted italic",children:"No renderable patch in this work tree."}):d.jsx("div",{className:"formula-run-diff-view mt-5 space-y-3",children:r.map(n=>d.jsx(Is,{file:n},`${n.oldRevision}:${n.newRevision}:${wr(n)}`))}),e.truncated&&d.jsx("p",{className:"mt-3 text-label uppercase tracking-wider text-fg-faint",children:"Diff truncated at the backend output cap."})]})}function Is({file:e}){const r=Fs(e.hunks);return d.jsxs("details",{className:"border-y border-rule py-2",open:!0,children:[d.jsxs("summary",{className:"cursor-pointer list-none text-label uppercase tracking-wider text-fg-muted",children:[d.jsx("span",{className:"font-medium normal-case tracking-normal text-body text-fg",children:wr(e)}),d.jsxs("span",{className:"ml-3 tnum text-fg-faint",children:["+",r.additions," -",r.deletions]})]}),e.hunks.length===0||e.isBinary?d.jsx("p",{className:"mt-3 text-body text-fg-muted italic",children:"No textual hunks."}):d.jsx("div",{className:"mt-3 overflow-auto",children:d.jsx(Ts,{viewType:"unified",diffType:e.type,hunks:e.hunks,renderGutter:Ps,children:n=>n.map(t=>d.jsx(yr,{hunk:t},Bs(t)))})})]})}function Rs(e){if(e.trim().length===0)return[];try{return dt(e,{nearbySequences:"zip"})}catch{return[]}}function Ps({change:e,side:r,renderDefault:n}){return e.type==="insert"&&r==="old"?d.jsx("span",{className:"diff-gutter-sign","aria-hidden":!0,children:"+"}):e.type==="delete"&&r==="new"?d.jsx("span",{className:"diff-gutter-sign","aria-hidden":!0,children:"-"}):n()}function $s(e){return e.kind==="upstream"?`Compared with ${e.ref} at ${e.mergeBase.slice(0,12)}.`:e.kind==="head"&&e.reason==="no_upstream"?"No upstream branch is configured; showing changes relative to HEAD plus untracked files.":e.kind==="head"?"Upstream comparison failed; showing changes relative to HEAD plus untracked files.":"Comparison unavailable."}function wr(e){const r=zn(e.oldPath),n=zn(e.newPath);return e.type==="delete"?r:e.type==="rename"&&r!==n?`${r} -> ${n}`:n||r}function zn(e){return e.replace(/^[ab]\//,"")}function Fs(e){let r=0,n=0;for(const t of e)for(const a of t.changes)a.type==="insert"&&(r+=1),a.type==="delete"&&(n+=1);return{additions:r,deletions:n}}function Bs(e){return`${e.oldStart}:${e.newStart}:${e.content}`}function Gs({node:e,visible:r}){const n=j.useMemo(()=>e?.executionInstances.sort(Nr)??[],[e]),t=j.useMemo(()=>zs(e?.visibleExecutionInstanceId,n),[e?.visibleExecutionInstanceId,n]),[a,s]=j.useState(null);if(j.useEffect(()=>{s(t?z(t):null)},[e?.id,t]),!e)return d.jsx("p",{className:"text-body text-fg-muted italic",children:"Select a node to inspect its session."});if(n.length===0)return d.jsx("p",{className:"text-body text-fg-muted italic",children:Kn(e)});const i=n.find(c=>z(c)===a)??t??n[0],o=i?we(i):"base",l=Ks(n),u=n.filter(c=>we(c)===o);return i?d.jsxs("section",{children:[d.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[d.jsx("h3",{className:"text-body font-semibold text-fg",children:e.title}),(e.historicalOnly||i?.historical)&&d.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint",children:e.historicalOnly?"historical-only":"historical"})]}),l.length>1&&d.jsxs("div",{className:"mt-3 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-label",role:"radiogroup","aria-label":"Iterations",children:[d.jsx("span",{className:"uppercase tracking-wider text-fg-faint",children:"Iterations"}),l.map(c=>{const f=c.instances.at(-1);if(!f)return null;const h=c.iteration==="base"?"Base":`Iteration ${c.iteration}`,g=c.iteration===o;return d.jsxs("span",{className:"flex items-baseline gap-1",children:[d.jsx("span",{"aria-hidden":!0,className:"text-fg-faint",children:"·"}),d.jsx("button",{type:"button",role:"radio","aria-checked":g,className:`focus-mark rounded-sm px-0.5 uppercase tracking-wider ${g?"text-fg font-semibold underline decoration-fg underline-offset-4":"text-fg-muted hover:text-fg"}`,onClick:()=>s(z(f)),children:h})]},h)})]}),u.length>1&&d.jsxs("div",{className:"mt-2 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-label",role:"radiogroup","aria-label":"Attempts",children:[d.jsx("span",{className:"uppercase tracking-wider text-fg-faint",children:"Attempts"}),u.map(c=>d.jsxs("span",{className:"flex items-baseline gap-1",children:[d.jsx("span",{"aria-hidden":!0,className:"text-fg-faint",children:"·"}),d.jsxs("button",{type:"button",role:"radio","aria-checked":z(c)===z(i),className:`focus-mark rounded-sm px-0.5 uppercase tracking-wider ${z(c)===z(i)?"text-fg font-semibold underline decoration-fg underline-offset-4":"text-fg-muted hover:text-fg"}`,onClick:()=>s(z(c)),children:["Attempt ",en(c)]})]},z(c)))]}),d.jsxs("dl",{className:"mt-4 grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-1 text-label",children:[d.jsx("dt",{className:"uppercase tracking-wider text-fg-faint",children:"Execution instance"}),d.jsx("dd",{className:"break-all text-fg-muted tnum",children:i.id}),d.jsx("dt",{className:"uppercase tracking-wider text-fg-faint",children:"Bead"}),d.jsx("dd",{className:"break-all text-fg-muted tnum",children:i.beadId})]}),d.jsx(xs,{instance:i,visible:r})]}):d.jsx("p",{className:"text-body text-fg-muted italic",children:Kn(e)})}function xs({instance:e,visible:r}){const n=e.session.kind==="attached"?e.session:null,t=n?.link.sessionId??null,a=r&&!!n?.streamable,s=Hr(t,a);if(n===null)return d.jsx("p",{className:"mt-5 text-body text-fg-muted italic",children:Us(e)});const i=Ls(s.stream),o=s.status==="loading",l=s.status==="ready"?s.result:null,u=s.status==="failed"?s.error:null,c=s.status==="ready"&&s.stream.status==="degraded"?s.stream.error:null;return d.jsxs("div",{className:"mt-5 space-y-4",children:[n?.streamable&&d.jsx("div",{className:"flex justify-end",children:d.jsx(Tr,{tone:i.tone,label:i.label,title:`Session stream: ${s.stream.status}`,className:"text-label uppercase tracking-wider"})}),c!==null&&d.jsx("p",{className:"text-accent",role:"alert",children:c}),d.jsx(Wr,{loading:o,error:u,result:l})]})}function Ls(e){switch(e.status){case"open":return{tone:"ok",label:"live"};case"connecting":return{tone:"warn",label:"connecting"};case"closed":return{tone:"stuck",label:"offline"};case"degraded":return{tone:"warn",label:"degraded"};case"idle":return{tone:"neutral",label:"snapshot"}}}function Kn(e){const r=e.executionInstances.filter(t=>t.session.kind==="none");return r.some(t=>t.currentIteration&&t.session.kind==="none"&&t.session.reason==="session_unresolved"&&_r(t.status))?"Session unresolved for the current running node.":r.some(t=>t.session.kind==="none"&&t.session.reason==="session_unresolved")?"Session unresolved for this node.":"This node has not started a session yet."}function Us(e){return e.session.kind==="attached"?"":e.currentIteration&&e.session.reason==="session_unresolved"&&_r(e.status)?"Session unresolved for the current running node.":e.session.reason==="session_unresolved"?"Session unresolved for this node.":"This node has not started a session yet."}function _r(e){return e==="active"||e==="running"}function zs(e,r){return(e?r.find(t=>z(t)===e):void 0)??r.at(-1)}function Ks(e){const r=new Map;for(const n of e){const t=we(n);r.set(t,[...r.get(t)??[],n])}return[...r.entries()].map(([n,t])=>({iteration:n,instances:t.sort(Nr)})).sort((n,t)=>Re(n.iteration)-Re(t.iteration))}function Nr(e,r){return Re(we(e))-Re(we(r))||en(e)-en(r)||e.id.localeCompare(r.id)}function z(e){return e.id}function we(e){return e.iteration.kind==="loop"?e.iteration.value:"base"}function Re(e){return e==="base"?0:e}function en(e){return e.attempt.kind==="attempt"?e.attempt.value:1}function Hs({tab:e,diff:r,selectedNode:n}){return e==="session"?d.jsx(Gs,{node:n,visible:!0}):d.jsx(Ws,{diff:r})}function Ws({diff:e}){switch(e.kind){case"idle":return d.jsx("p",{className:"text-body text-fg-muted italic",children:"Local changes are not loaded for this run."});case"loading":return d.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading local changes."});case"failed":return d.jsx("p",{className:"text-body text-accent",role:"alert",children:e.error});case"ready":return d.jsxs(d.Fragment,{children:[e.refreshState.kind==="failed"&&d.jsx("p",{className:"mb-4 text-body text-accent",role:"alert",children:e.refreshState.error}),e.refreshState.kind==="refreshing"&&d.jsx("p",{className:"mb-4 text-label uppercase tracking-wider text-fg-faint",role:"status",children:"Refreshing local changes"}),d.jsx(Ms,{diff:e.diff})]})}}function Vs({diff:e,selectedNode:r,activeTab:n,onActiveTabChange:t}){const[a,s]=j.useState("diff"),i=n!==void 0&&t!==void 0,o=i?n:a,l=c=>{i?t(c):s(c)},u=`run-evidence-tab-${o}`;return d.jsxs("section",{"aria-label":"Run evidence",children:[d.jsxs("div",{className:"flex items-baseline gap-2 text-label",role:"tablist","aria-label":"Run evidence views",children:[d.jsx(Hn,{id:"run-evidence-tab-diff",controls:"run-evidence-panel",active:o==="diff",onClick:()=>l("diff"),children:"Diff"}),d.jsx("span",{"aria-hidden":!0,className:"text-fg-faint",children:"·"}),d.jsx(Hn,{id:"run-evidence-tab-session",controls:"run-evidence-panel",active:o==="session",onClick:()=>l("session"),children:"Session"})]}),d.jsx("div",{id:"run-evidence-panel",role:"tabpanel","aria-labelledby":u,className:"pt-5",children:d.jsx(Hs,{tab:o,diff:e,selectedNode:r})})]})}function Hn({id:e,controls:r,active:n,disabled:t=!1,onClick:a,children:s}){return d.jsx("button",{id:e,type:"button",role:"tab","aria-selected":n,"aria-controls":r,"aria-disabled":t||void 0,disabled:t,className:`focus-mark rounded-sm px-0.5 uppercase tracking-wider ${t?"cursor-not-allowed text-fg-faint":n?"text-fg font-semibold underline decoration-fg underline-offset-4":"text-fg-muted hover:text-fg"}`,onClick:a,children:s})}function Xs(e,r){const n=e.runIds.size===0||e.runIds.has(r.runId),t=e.rootBeadIds.size===0||e.rootBeadIds.has(r.rootBeadId);return n&&t}function Zs(e){const r={runIds:new Set,rootBeadIds:new Set};return Y(e,r),Y($(e.run),r),Y($(e.payload),r),Y($($(e.payload)?.run),r),Y($(e.bead),r),Y($($(e.payload)?.bead),r),Y($(e.root),r),Y($($(e.payload)?.root),r),nn($(e.metadata),r),nn($($(e.payload)?.metadata),r),r}function Y(e,r){e&&(H(r.runIds,e.run_id),H(r.runIds,e.workflow_id),H(r.rootBeadIds,e.root_bead_id),nn($(e.metadata),r))}function nn(e,r){e&&(H(r.runIds,e["gc.run_id"]),H(r.runIds,e["gc.workflow_id"]),H(r.runIds,e.run_id),H(r.runIds,e.workflow_id),H(r.rootBeadIds,e["gc.root_bead_id"]),H(r.rootBeadIds,e.root_bead_id))}function H(e,r){if(typeof r!="string")return;const n=r.trim();n&&e.add(n)}function $(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)?e:void 0}function Js(e,r,n){const[t,a]=j.useState({nodeId:null,routeKey:"",source:"route"});j.useEffect(()=>{if(!e)return;const u=Ys(e,r);a(c=>c.routeKey===n&&(c.source==="user"||c.nodeId===u)?c:{nodeId:u,routeKey:n,source:"route"})},[e,n,r]);const s=j.useCallback(()=>{a(u=>({nodeId:null,routeKey:u.routeKey,source:"user"}))},[]);j.useEffect(()=>{const u=c=>{c.key==="Escape"&&s()};return window.addEventListener("keydown",u),()=>window.removeEventListener("keydown",u)},[s]);const i=j.useCallback(u=>{a(c=>({nodeId:c.nodeId===u?null:u,routeKey:n,source:"user"}))},[n]),o=t.nodeId,l=j.useMemo(()=>e?.nodes.find(u=>u.id===o)??null,[e,o]);return{selectedNodeId:o,selectedNode:l,toggleNode:i,clearSelection:s}}function Ys(e,r){return r&&e.nodes.some(n=>n.id===r)?r:null}const Wn=[600,1200,2400],Qs=5e3,qs=18e4;async function ei(e,r){let n=0;for(let t=0;;t+=1)try{return await Pe.runDetail(e)}catch(a){const s=ni(a,t,n);if(s===void 0||r?.keepPolling?.()===!1||(jr(a)&&r?.onWarming?.({reason:a.reason}),n+=s,await ti(s),r?.keepPolling?.()===!1))throw a}}function ni(e,r,n){if(jr(e)){const t=Wn[r]??Qs;return n+t<=qs?t:void 0}return ri(e)?Wn[r]:void 0}function jr(e){return e instanceof Oe&&e.status===503}function ri(e){return e instanceof Oe?e.status>=500:e instanceof TypeError}function ti(e){return new Promise(r=>setTimeout(r,e))}function ai(e,r,n,t,a){const[s,i]=j.useState("unavailable"),o=j.useRef(n);o.current=n;const l=j.useRef(!1),u=Sr(e,t,a);return j.useEffect(()=>{if(l.current=!1,!e||!r||typeof EventSource>"u"){i("unavailable");return}let c=!1;i("connecting");const f=new EventSource(Pe.runDetailStreamUrl(e),{withCredentials:!0});f.onopen=()=>{c||i("open")};const h=g=>{if(c)return;const m=si(g.data,e,l);m!==null&&(Mr(u,{kind:"loaded",detail:m}),o.current?.(m,u),i("open"))};return f.addEventListener("detail",h),f.onerror=()=>{c||i(f.readyState===EventSource.CLOSED?"closed":"connecting")},()=>{c=!0,f.close()}},[e,r,u]),s}function si(e,r,n){let t;try{t=JSON.parse(e)}catch(a){return Vn(r,n,a),null}try{return Or(t,Pe.runDetailStreamUrl(r))}catch(a){return Vn(r,n,a),null}}function Vn(e,r,n){r.current||(r.current=!0,rn({component:"formula-run-detail-stream",operation:"parse stream frame",message:`${e}: ${tn(n)}`}))}function ii(e,r,n){const t=Sr(e,r,n),[a,s]=j.useState(null),i=j.useRef(0);j.useEffect(()=>()=>{i.current+=1},[]);const{data:o,loading:l,error:u,refresh:c}=Jn(t,()=>{const _=++i.current,N=()=>i.current===_;return oi(e,{onWarming:S=>{N()&&s(S)},keepPolling:N}).finally(()=>{N()&&s(null)})},{onError:_=>{e!==void 0&&ci("load detail",e,_)}}),[f,h]=j.useState(null),g=j.useCallback((_,N)=>h({key:N,detail:_}),[]),m=e!==void 0&&o?.kind!=="unsupported"&&o?.kind!=="not_found",v=ai(e,m,g,r,n),b=f?.key===t?f.detail:null,p=v==="open"||v==="connecting",w=j.useCallback(async()=>{h(null),await c()},[c]);if(e===void 0)return{kind:"idle",refresh:li,streamActive:p};const y=b??(o?.kind==="loaded"?o.detail:null);return y!==null?{kind:"ready",detail:y,refresh:w,refreshState:ui(l,u),streamActive:p}:o?.kind==="unsupported"?{kind:"unsupported",refresh:w,streamActive:p}:o?.kind==="not_found"?{kind:"not_found",refresh:w,streamActive:p}:u!==null?{kind:"failed",error:u,refresh:w,streamActive:p}:{kind:"loading",warming:a,refresh:w,streamActive:p}}async function oi(e,r){if(!e)return{kind:"unrequested"};try{return{kind:"loaded",detail:await ei(e,r)}}catch(n){if(n instanceof Oe&&n.status===422&&n.reason==="not_run_view")return{kind:"unsupported"};if(n instanceof Oe&&n.status===404)return{kind:"not_found"};throw n}}async function li(){}function ui(e,r){return r!==null?{kind:"failed",error:r}:e?{kind:"refreshing"}:{kind:"idle"}}function ci(e,r,n){rn({component:"formula-run-detail",operation:e,message:`${r}: ${tn(n)}`})}function Sr(e,r,n){return["formula-run",e??"missing",r??"default",n??"default"].map(encodeURIComponent).join(":")}function fi(e,r,n,t){const a=gi(e,r,n,t),{data:s,loading:i,error:o,refresh:l,cheapRefresh:u}=Jn(a,()=>He(e,r,n,t),{refreshFetcher:()=>He(e,r,n,t,!0),sseRefreshFetcher:()=>He(e,r,n,t,!1),onError:c=>{e!==void 0&&hi("load diff",e,c)}});return e===void 0||r===void 0?{kind:"idle",refresh:Xn,cheapRefresh:Xn}:s?.kind==="loaded"?{kind:"ready",diff:s.diff,refresh:l,cheapRefresh:u,refreshState:di(i,o)}:o!==null?{kind:"failed",error:o,refresh:l,cheapRefresh:u}:{kind:"loading",refresh:l,cheapRefresh:u}}async function He(e,r,n,t,a){if(!e||r===void 0)return{kind:"unrequested"};const s={};return n!==void 0&&(s.scopeKind=n),t!==void 0&&(s.scopeRef=t),a&&(s.refresh=!0),{kind:"loaded",diff:await Pe.runDiff(e,{executionPath:r},s)}}async function Xn(){}function di(e,r){return r!==null?{kind:"failed",error:r}:e?{kind:"refreshing"}:{kind:"idle"}}function hi(e,r,n){rn({component:"formula-run-detail",operation:e,message:`${r}: ${tn(n)}`})}function gi(e,r,n,t){return["formula-run-diff",e??"missing",mi(r),n??"default",t??"default"].join(":")}function mi(e){return e===void 0?"path:missing":e.kind==="known"?`path:${e.path}`:`path:${e.reason}`}const vi=[wn.bead,wn.session],bi=[];function Li(){const{runId:e}=Ir(),[r]=Rr(),n=Ti(r),t=n.ok?n.scope:void 0,a=n.ok?null:n.error,s=r.get("node"),i=[e??"",t?.scopeKind??"",t?.scopeRef??"",s??""].join("\0"),o=ii(a?void 0:e,t?.scopeKind,t?.scopeRef),l=o.kind==="ready"?o:null,u=l?.detail??null,c=o.kind==="unsupported",f=o.kind==="not_found",h=fi(a||u===null?void 0:e,u?.executionPath,t?.scopeKind,t?.scopeRef),g=o.kind==="loading",m=l!==null&&l.refreshState.kind==="refreshing"||h.kind==="ready"&&h.refreshState.kind==="refreshing",v=u!==null&&h.kind==="loading",b=g||m||v,p=o.kind==="failed"?o.error:l!==null&&l.refreshState.kind==="failed"?l.refreshState.error:null,[w,y]=j.useState("diff"),_=w==="diff",N=o.streamActive;Pr(a?bi:vi,()=>{pi(N,_,o.refresh,h.cheapRefresh)},{matches:I=>{const J=Zs(I);return u===null?e!==void 0&&(J.runIds.size===0||J.runIds.has(e)):u.progress.terminal&&wi(J)?!1:Xs(J,{runId:u.runId,rootBeadId:u.rootBeadId})}});const C=j.useRef(h.cheapRefresh);C.current=h.cheapRefresh;const S=j.useRef(_);j.useEffect(()=>{const I=S.current;S.current=_,_&&!I&&C.current()},[_]);const A=j.useCallback(I=>y(I),[]),k=a??p,E=o.kind==="loading"&&o.warming?.reason==="unknown_run",{selectedNodeId:D,selectedNode:B,toggleNode:M}=Js(u,s,i),R=Ur(u?.rootBeadId??null),[G,L]=j.useState(null),X=$r(),ee=xr(),[Z]=j.useState(()=>Fr(`runs:summary:${ee??"no-city"}`)),ge=j.useMemo(()=>{if(!e)return null;const I=Z&&Z.status!=="error"?Z.data:null;return I==null?null:[...I.lanes,...I.blockedLanes].find(J=>J.id===e)??null},[Z,e]),U=u?`${u.progress.visibleNodeCount} nodes. ${Mi(u.progress)}. Local changes are shown for the run execution folder.`:g&&!a||c||f?void 0:"Formula run unavailable.";return d.jsxs("section",{children:[d.jsx(Lr,{title:u?.title??"Formula Run",synopsis:U,meta:d.jsxs(d.Fragment,{children:[d.jsx(Br,{to:"/runs",className:"focus-mark text-label uppercase tracking-wider text-fg-muted hover:text-fg",children:"Runs"}),k&&u&&d.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:k}),u&&d.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:ji(u)}),d.jsx(Gr,{size:"sm",onClick:()=>{kr(o.refresh,h.refresh)},disabled:b||!!a,children:m?"Refreshing":"Refresh"})]})}),b&&!a&&!u?ge?d.jsxs(d.Fragment,{children:[d.jsx(_n,{stages:ge.stages,label:ge.title}),d.jsx("p",{className:"text-body text-fg-muted italic mt-8",children:"Loading run detail."})]}):E?d.jsx("p",{className:"text-body text-fg-muted italic",role:"status",children:"This run may still be being recorded — new work can take a couple of minutes to appear — or it may no longer exist."}):d.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading formula run."}):c?d.jsx("p",{className:"text-body text-fg-muted",role:"status",children:"Detailed step view isn’t available for this run (v1/wisp runs are list-only) — this run appears in the run list only."}):f?d.jsx("p",{className:"text-body text-fg-muted",role:"status",children:"This run’s detail snapshot was not found. It may be a v1/wisp run, a completed run whose snapshot wasn’t retained, or no longer available."}):k&&!u?d.jsx("p",{className:"text-body text-accent",role:"alert",children:k}):l?d.jsxs(d.Fragment,{children:[d.jsx(_i,{detail:l.detail}),d.jsx(_n,{stages:l.detail.stages,label:l.detail.title}),d.jsx(ki,{detail:l.detail}),d.jsxs("div",{className:"mt-8 grid gap-10 lg:grid-cols-[minmax(0,0.95fr)_minmax(22rem,1.05fr)]",children:[d.jsx(et,{detail:l.detail,selectedNodeId:D,onToggleNode:M}),d.jsx(Vs,{diff:h,selectedNode:B,activeTab:w,onActiveTabChange:A})]}),d.jsx(zr,{view:R.view,loading:R.loading,error:R.error,now:X,onOpenBead:L}),d.jsx(Kr,{open:G!==null,onClose:()=>L(null),beadId:G,onOpenBead:L})]}):null]})}async function kr(e,r){await Promise.all([e(),r()])}function pi(e,r,n,t){const a=r?t:yi;return e?a():kr(n,a)}async function yi(){}function wi(e){return e.runIds.size===0&&e.rootBeadIds.size===0}function _i({detail:e}){const r=Si(e.formulaDetail);return d.jsxs("dl",{className:"grid gap-x-8 gap-y-3 sm:grid-cols-2 lg:grid-cols-4",children:[d.jsx(Ni,{formula:e.formula}),r!==null&&d.jsx(ce,{label:"Formula Detail",value:r}),d.jsx(ce,{label:"Root",value:e.rootBeadId}),d.jsx(ce,{label:"Scope",value:`${e.scopeKind}:${e.scopeRef}`}),d.jsx(ce,{label:"Store",value:e.resolvedRootStore||e.rootStoreRef||"unknown"})]})}function ce({label:e,value:r}){return d.jsxs("div",{children:[d.jsx("dt",{className:"text-label uppercase tracking-wider text-fg-faint",children:e}),d.jsx("dd",{className:"text-body text-fg break-all tnum",children:r})]})}const Zn="name inferred from bead title — supervisor did not set gc.formula on this graph.v2 root";function Ni({formula:e}){if(e.kind!=="known")return d.jsx(ce,{label:"Formula",value:"metadata missing"});switch(e.source){case"metadata":return d.jsx(ce,{label:"Formula",value:e.name});case"title_fallback":return d.jsxs("div",{children:[d.jsx("dt",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Formula"}),d.jsxs("dd",{className:"text-body text-warn break-all tnum",title:Zn,"aria-label":`${e.name} (${Zn})`,children:[e.name,d.jsx("span",{className:"ml-2 text-label uppercase tracking-wider text-warn",children:"inferred from bead title"})]})]});default:return e.source}}function ji(e){return e.snapshotEventSeq.kind==="known"?`v${e.snapshotVersion} · seq ${e.snapshotEventSeq.seq}`:`v${e.snapshotVersion}`}function Si(e){return e.kind==="available"?`available for ${e.target}`:e.reason==="missing_formula_metadata"?null:e.reason==="missing_run_target"?`missing run target for ${e.name}`:`${e.failure} for ${e.target}`}function ki({detail:e}){if(e.completeness.kind!=="partial")return null;const r=Ci(e.completeness.reasons);return r.length===0?null:d.jsxs("p",{className:"mt-5 text-label uppercase tracking-wider text-warn",role:"status",children:["Partial run data: ",Ei(r),"."]})}function Ci(e){return e.filter(r=>!Ai(r))}function Ai(e){switch(e){case"formula_detail_missing_formula_metadata":case"formula_detail_missing_run_target":case"formula_detail_fetch_failed":return!0;case"supervisor_snapshot_partial":case"runtime_bead_read_failed":case"session_list_failed":return!1}}function Ei(e){return e.map(Di).join(", ")}function Di(e){switch(e){case"supervisor_snapshot_partial":return"supervisor snapshot is partial";case"runtime_bead_read_failed":return"runtime bead refresh failed";case"session_list_failed":return"session list failed";case"formula_detail_missing_formula_metadata":return"formula metadata is missing";case"formula_detail_missing_run_target":return"formula run target is missing";case"formula_detail_fetch_failed":return"formula detail fetch failed"}}function Ti(e){const r=e.getAll("scope_kind"),n=e.getAll("scope_ref");if(r.length>1||n.length>1)return{ok:!1,error:"Invalid run scope query."};const t=r[0],a=n[0];return t===void 0&&a===void 0?{ok:!0}:t===void 0||a===void 0?{ok:!1,error:"Invalid run scope query."}:t!=="city"&&t!=="rig"?{ok:!1,error:"Invalid run scope query."}:Vr.test(a)?{ok:!0,scope:{scopeKind:t,scopeRef:a}}:{ok:!1,error:"Invalid run scope query."}}function Mi(e){const r=[re(e,["active","running"],"running"),re(e,["completed","done"],"done"),re(e,"ready","ready"),re(e,"blocked","blocked"),re(e,"failed","failed"),re(e,"skipped","skipped"),re(e,"pending","pending")].filter(n=>n!==null);return r.length>0?r.join(", "):"No node status yet"}function re(e,r,n){const a=(typeof r=="string"?[r]:r).reduce((s,i)=>s+(e.statusCounts[i]??0),0);return a>0?`${a} ${n}`:null}export{Li as FormulaRunDetailPage,pi as runDetailNudgeRefresh}; diff --git a/internal/api/dashboardspa/dist/assets/Health-C5mLLJQ2.js b/internal/api/dashboardspa/dist/assets/Health-C5mLLJQ2.js deleted file mode 100644 index 29f43835b3..0000000000 --- a/internal/api/dashboardspa/dist/assets/Health-C5mLLJQ2.js +++ /dev/null @@ -1 +0,0 @@ -import{a as Y,b as v,r as Z,j as t,B as ee,X as y,z as E,S as V,H as F,ab as te}from"./index-C20tCZFz.js";import{p as $,d as ae}from"./routeHighlight-B30gQO2o.js";import{P as se}from"./PageHeader-D_D-jYn1.js";import{u as le}from"./useVisibleRefresh-D_HCcAAw.js";import{a as x}from"./format-fte2CeYD.js";import{a as ne}from"./time-D9v0saHV.js";const re=2500;function Ee(){const e=Y(),a=F(),s=v("health:system",ye),o=v(`health:supervisor:${a??"no-city"}`,_e),r=v(`health:status:${a??"no-city"}`,we),c=v("health:local-tools",Ne),b=v(`health:dolt-noms-trend:${a??"no-city"}`,Se),p=v(`health:rig-store:${a??"no-city"}`,ke),d=s.refresh,_=o.refresh,w=r.refresh,N=c.refresh,C=b.refresh,L=p.refresh,W=s.loading||o.loading||r.loading||c.loading||b.loading||p.loading,T=[s.error,o.error,r.error,c.error,b.error,p.error].filter(J=>J!==null).join("; ")||null,D=Z.useCallback(async()=>{await Promise.all([d(),_(),w(),N(),C(),L()])},[C,N,L,_,w,d]),m=s.data??null,n=m?.status==="available"?m.data:null,S=m?.status==="unavailable"?m.error:null,i=o.data??null,k=r.data??null,U=c.data??null,u=b.data??null,f=p.data??null,B=f?pe(f):void 0,R=m!==null||i!==null||k!==null||U!==null||u!==null||f!==null,M=n?He(n):void 0,X=$(e,"health",["health:supervisor-"]),q=$(e,"health",["health:load-","health:memory-"]),Q=$(e,"health",["health:dashboard-"]),G=$(e,"health",["health:dolt-noms-"]);return le(D,3e4),t.jsxs("section",{children:[t.jsx(se,{title:"Health",synopsis:R?$e(n,i):"Reading state from the supervisor.",meta:t.jsxs(t.Fragment,{children:[T&&t.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:T}),t.jsx(ee,{size:"sm",onClick:()=>{D()},children:W&&!R?"Loading":"Refresh"})]})}),R?t.jsxs("div",{className:"space-y-12",children:[t.jsx(h,{title:"Supervisor",attention:X,...i?{status:Re(i)}:{},children:i===null?t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading supervisor state."}):i.status==="available"?t.jsxs(g,{children:[i.data.city!==void 0?t.jsx(l,{label:"City",value:i.data.city}):t.jsx(l,{label:"City",value:"not reported by supervisor",tone:"warn"}),i.data.version!==void 0?t.jsx(l,{label:"Version",value:i.data.version}):t.jsx(l,{label:"Version",value:"not reported by supervisor",tone:"warn"}),t.jsx(l,{label:"Uptime",value:j(i.data.uptime_sec)}),t.jsx(l,{label:"Status",value:i.data.status})]}):t.jsx("p",{className:"text-body text-accent",children:"Supervisor not reachable. The dashboard shell stays up; live data is stale."})}),t.jsx(h,{title:"Host",attention:q,...M?{status:M}:{},children:m===null?t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading dashboard host health."}):n===null?t.jsxs("p",{className:"text-body text-accent",children:["Dashboard host health unavailable",S?`: ${S}`:"","."]}):t.jsxs(g,{children:[t.jsx(l,{label:"CPUs",value:n.host.cpu_count.toString()}),t.jsx(l,{label:"Load (1m, 5m, 15m)",value:`${n.host.load_avg_1.toFixed(2)}, ${n.host.load_avg_5.toFixed(2)}, ${n.host.load_avg_15.toFixed(2)}`,...n.host.load_avg_1>n.host.cpu_count?{tone:"warn"}:{}}),t.jsx(l,{label:"Memory free",value:`${x(n.host.free_mem_bytes)} of ${x(n.host.total_mem_bytes)}`,...n.host.free_mem_bytes/n.host.total_mem_bytes<.1?{tone:"warn"}:{}}),t.jsx(l,{label:"Host uptime",value:j(n.host.uptime_sec)})]})}),t.jsx(h,{title:"Admin process",attention:Q,children:m===null?t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading dashboard process health."}):n===null?t.jsxs("p",{className:"text-body text-accent",children:["Dashboard process health unavailable",S?`: ${S}`:"","."]}):t.jsxs(g,{children:[t.jsx(l,{label:"PID",value:n.admin.pid.toString()}),t.jsx(l,{label:"Uptime",value:j(n.admin.uptime_sec)}),t.jsx(l,{label:"RSS",value:x(n.admin.rss_bytes)}),t.jsx(l,{label:"Heap used",value:x(n.admin.heap_used_bytes)}),t.jsx(l,{label:"Node",value:n.admin.node_version})]})}),t.jsx(h,{title:"Tool versions",children:t.jsx(oe,{state:U})}),t.jsx(h,{title:"Diagnostics",children:t.jsxs("div",{className:"space-y-8",children:[t.jsx(ce,{usage:K(k)}),t.jsx(ue,{usage:Ce(k)})]})}),t.jsx(h,{title:"Bead stores · per rig",meta:be(f),...B?{status:B}:{},children:t.jsx(de,{report:f})}),t.jsx(h,{title:"Store thresholds",children:t.jsx(ve,{comparison:Le(k)})}),t.jsx(h,{title:"Dolt-noms · 24 h",attention:G,meta:u&&u.samples.length>0?`${u.samples.length} samples`:void 0,children:u===null?t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading."}):u.available?u.samples.length===0?t.jsx("p",{className:"text-body text-fg-muted italic",children:"No samples yet. Backend just started; next sample in ten minutes or less."}):t.jsx(ge,{samples:u.samples}):t.jsxs("p",{className:"text-body text-fg-muted italic",children:["Dolt-noms metric unavailable: ",je(u.reason),"."]})})]}):t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading."})]})}function h({title:e,status:a,meta:s,attention:o,children:r}){return t.jsxs("section",{...ae(o??null),children:[t.jsxs("header",{className:"flex items-baseline justify-between gap-4 mb-4 pb-2 border-b border-rule",children:[t.jsx("h2",{className:"text-headline font-semibold text-fg",children:e}),t.jsxs("div",{className:"flex items-baseline gap-4",children:[s&&t.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:s}),a&&t.jsx(V,{tone:a.tone,label:a.label})]})]}),r]})}function g({children:e}){return t.jsx("dl",{className:"grid grid-cols-[max-content_1fr] gap-x-8 gap-y-3 max-w-prose",children:e})}function l({label:e,value:a,tone:s}){const o=s==="warn"?"text-warn":s==="stuck"?"text-accent":"text-fg";return t.jsxs(t.Fragment,{children:[t.jsx("dt",{className:"text-body text-fg-muted",children:e}),t.jsx("dd",{className:`text-body tnum font-medium ${o}`,children:a})]})}function oe({state:e}){if(e===null)return t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading tool versions."});if(e.status==="unavailable")return t.jsxs("p",{className:"text-body text-fg-muted italic",children:["Tool versions unavailable: ",e.error,"."]});const a=[{label:"gc",tool:e.data.gc},{label:"bd",tool:e.data.beads},{label:"dolt",tool:e.data.dolt}];return t.jsxs("div",{className:"grid grid-cols-[1fr_max-content] gap-x-8 gap-y-3 max-w-prose",children:[t.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Tool"}),t.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted text-right",children:"Installed"}),a.map(s=>t.jsx(ie,{label:s.label,tool:s.tool},s.label))]})}function ie({label:e,tool:a}){return t.jsxs("div",{className:"contents","data-tool-version-row":e,children:[t.jsx("div",{className:"text-body text-fg",children:e}),t.jsx("div",{className:"text-right",children:a.status==="available"?t.jsx("span",{className:"text-body tnum font-medium text-fg",children:a.version}):t.jsxs("div",{className:"space-y-1",children:[t.jsx("div",{className:"text-body tnum font-medium text-warn",children:"unavailable"}),t.jsx("div",{className:"text-label text-fg-muted normal-case",children:a.reason})]})})]})}function ce({usage:e}){if(e.status==="unavailable")return t.jsx(O,{heading:"Dolt usage",reason:e.reason});const a=e.value;return t.jsxs("div",{className:"space-y-2",children:[t.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Dolt usage"}),e.stale!==void 0&&t.jsx(H,{message:e.stale}),t.jsxs(g,{children:[t.jsx(l,{label:"On-disk size",value:x(Te(a.size_bytes))}),t.jsx(l,{label:"Live rows",value:a.live_rows.toLocaleString()}),t.jsx(l,{label:"MB per row",value:a.ratio_mb_per_row.toString()}),t.jsx(l,{label:"Last maintenance",value:a.last_gc_status??"not reported",...a.last_gc_status!==void 0&&a.last_gc_status!=="success"?{tone:"warn"}:{}}),a.last_gc_at!==void 0&&t.jsx(l,{label:"Last maintenance at",value:ne(a.last_gc_at)}),t.jsx(l,{label:"Store path",value:a.path})]})]})}function ue({usage:e}){if(e.status==="unavailable")return t.jsx(O,{heading:"Beads usage",reason:e.reason});const a=e.value;return t.jsxs("div",{className:"space-y-2",children:[t.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Beads usage"}),e.stale!==void 0&&t.jsx(H,{message:e.stale}),t.jsxs(g,{children:[t.jsx(l,{label:"Open",value:a.open.toString()}),t.jsx(l,{label:"Ready",value:a.ready.toString()}),t.jsx(l,{label:"In progress",value:a.in_progress.toString()})]})]})}function de({report:e}){if(e===null)return t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading per-rig store health."});if(!e.available&&e.rigs.length===0)return t.jsxs("p",{className:"text-body text-fg-muted italic",children:["Per-rig store health unavailable: ",P(e.reason),"."]});const a=[...e.rigs].sort((s,o)=>A(o.rollup)-A(s.rollup));return t.jsxs("div",{className:"space-y-6 max-w-prose",children:[!e.available&&t.jsxs("p",{className:"text-body text-warn italic",children:["Showing the last sample; refresh failed: ",P(e.reason),"."]}),a.map(s=>t.jsx(he,{rig:s},s.rig))]})}function he({rig:e}){const a=xe(e);return t.jsxs("div",{className:"space-y-2 border-b border-rule pb-4 last:border-b-0",children:[t.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[t.jsx("span",{className:"text-body font-medium text-fg",children:e.rig}),t.jsx(V,{tone:a.tone,label:a.label})]}),t.jsxs("dl",{className:"grid grid-cols-[max-content_1fr] gap-x-6 gap-y-1",children:[t.jsx(l,{label:"Dolt server",value:me(e),...e.doltConnected===!1?{tone:"stuck"}:{}}),e.issueCount!==null&&t.jsx(l,{label:"Live issues",value:e.issueCount.toLocaleString()})]}),e.problems.length>0&&t.jsx("ul",{className:"space-y-1",children:e.problems.map(s=>t.jsxs("li",{className:`text-label ${s.status==="error"?"text-accent":"text-warn"}`,children:[s.name,": ",s.message]},`${s.category}/${s.name}`))}),e.note!==void 0&&t.jsx("p",{className:"text-label text-fg-muted italic",children:e.note})]})}function me(e){const a=e.doltEndpoint??"no endpoint reported";return e.doltConnected===!0?`up · ${a}`:e.doltConnected===!1?`DOWN · ${a}`:`unknown · ${a}`}function xe(e){switch(e.rollup){case"ok":return{tone:"ok",label:"healthy"};case"warn":return{tone:"warn",label:"warnings"};case"down":return e.reachable?e.doltConnected===!1?{tone:"stuck",label:"dolt down"}:{tone:"stuck",label:"errors"}:{tone:"stuck",label:"unreachable"}}}function A(e){return e==="down"?2:e==="warn"?1:0}function be(e){if(e===null||e.rigs.length===0)return;const a={ok:0,warn:0,down:0};for(const s of e.rigs)a[s.rollup]+=1;return`${a.ok} ok · ${a.warn} warn · ${a.down} down`}function pe(e){if(e.rigs.some(a=>a.rollup==="down"))return{tone:"stuck",label:"attention"};if(e.rigs.some(a=>a.rollup==="warn"))return{tone:"warn",label:"warnings"};if(e.rigs.length>0)return{tone:"ok",label:"healthy"}}function P(e){switch(e){case"not_sampled_yet":return"backend just started; first sample is in flight";case"rig_list_failed":return"the supervisor rig list could not be read";case"fetch_failed":return"the dashboard backend could not be reached"}}function ve({comparison:e}){return e.status==="unavailable"?t.jsxs("p",{className:"text-body text-fg-muted italic",children:["Comparison unavailable: ",e.reason,"."]}):t.jsxs("div",{className:"space-y-2",children:[e.stale!==void 0&&t.jsx(H,{message:e.stale}),t.jsxs("div",{className:"grid grid-cols-[1fr_max-content_max-content] gap-x-8 gap-y-3 max-w-prose",children:[t.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Setting"}),t.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted text-right",children:"Recommended"}),t.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted text-right",children:"Loaded"}),e.value.map(a=>t.jsx(fe,{row:a},a.label))]})]})}function fe({row:e}){const a=e.withinRecommendation?"text-fg":"text-warn";return t.jsxs("div",{className:`contents ${a}`,"data-comparison-row":e.label,children:[t.jsxs("div",{className:`text-body ${a}`,children:[e.label,!e.withinRecommendation&&t.jsx("span",{className:"text-label uppercase tracking-wider text-warn",children:" · over"})]}),t.jsx("div",{className:"text-body tnum text-fg-muted text-right",children:e.recommended}),t.jsx("div",{className:`text-body tnum font-medium text-right ${a}`,children:e.loaded})]})}function O({heading:e,reason:a}){return t.jsxs("div",{className:"space-y-2",children:[t.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-muted",children:e}),t.jsxs("p",{className:"text-body text-fg-muted italic",children:["Unavailable: ",a,"."]})]})}function H({message:e}){return t.jsx("p",{className:"text-body text-warn italic",children:e})}function ge({samples:e}){if(e.length===0)return null;const a=Math.max(...e.map(d=>d.bytes)),s=Math.min(...e.map(d=>d.bytes)),o=a-s||1,r=600,c=60,b=e.length>1?r/(e.length-1):r,p=e.map((d,_)=>{const w=_*b,N=c-(d.bytes-s)/o*c;return`${w.toFixed(1)},${N.toFixed(1)}`}).join(" ");return t.jsxs("div",{className:"space-y-3 max-w-prose",children:[t.jsx("svg",{viewBox:`0 0 ${r} ${c}`,preserveAspectRatio:"none",className:"w-full h-16","aria-label":"24 hour dolt-noms size trend",children:t.jsx("polyline",{fill:"none",stroke:"currentColor",strokeWidth:"1",className:"text-accent",points:p})}),t.jsxs("div",{className:"flex items-baseline justify-between text-label uppercase tracking-wider text-fg-muted tnum",children:[t.jsxs("span",{children:["min ",x(s)]}),t.jsxs("span",{children:["max ",x(a)]})]})]})}function je(e){switch(e){case"store_health_absent":return"supervisor is not reporting store_health; samples resume when it recovers";case"sample_failed":return"latest supervisor status read failed; check the backend log"}}async function ye(){try{return{status:"available",data:await y.systemHealth()}}catch(e){return{status:"unavailable",error:E(e,"dashboard host health unavailable")}}}async function _e(){const e=F();if(e===null)throw new Error("Health page loaded before an active city was resolved");try{return{status:"available",data:await te(re).cityHealth(e)}}catch{return{status:"unavailable",error:"supervisor health unavailable"}}}function z(e){switch(e){case"not_sampled_yet":return"supervisor status sample is warming up; data appears after the next backend sample";case"status_read_failed":return"latest supervisor status read failed; check the backend log"}}function I(e){return`Showing the last sample; refresh failed: ${z(e)}.`}async function we(){try{const e=await y.supervisorStatus();return e.available?{status:"available",data:e.status,staleReason:null}:e.status!==null?{status:"available",data:e.status,staleReason:e.reason}:{status:"unavailable",error:z(e.reason)}}catch(e){return{status:"unavailable",error:E(e,"supervisor status unavailable")}}}async function Ne(){try{return{status:"available",data:await y.localToolVersions()}}catch{return{status:"unavailable",error:"local tool versions unavailable"}}}async function Se(){try{return await y.doltTrend()}catch{return{available:!1,reason:"sample_failed",samples:[]}}}async function ke(){try{return await y.rigStoreHealth()}catch{return{available:!1,reason:"fetch_failed",rigs:[]}}}function $e(e,a){const s=[];if(a===null)s.push("Supervisor state still loading.");else if(a.status==="available"){const r=a.data,c=r.status==="ok"?"healthy":r.status;r.city!==void 0?s.push(`Supervisor ${c} on ${r.city}, uptime ${j(r.uptime_sec)}.`):s.push(`Supervisor ${c}, uptime ${j(r.uptime_sec)}.`)}else s.push("Supervisor unreachable.");if(e===null)return s.push("Host health unavailable."),s.join(" ");const o=Math.round(100*(1-e.host.free_mem_bytes/e.host.total_mem_bytes));return s.push(`Memory at ${o}%; ${e.host.cpu_count} CPUs averaging ${e.host.load_avg_1.toFixed(2)} load.`),s.join(" ")}function Re(e){return e.status==="unavailable"?{tone:"stuck",label:"offline"}:e.data.status==="ok"?{tone:"ok",label:"healthy"}:{tone:"warn",label:e.data.status}}function He(e){const a=e.host.free_mem_bytes/e.host.total_mem_bytes;if(a<.05)return{tone:"stuck",label:"memory critical"};if(a<.1)return{tone:"warn",label:"memory low"};if(e.host.load_avg_1>e.host.cpu_count*1.5)return{tone:"warn",label:"load high"}}function K(e){if(e===null)return{status:"unavailable",reason:"supervisor status still loading"};if(e.status==="unavailable")return{status:"unavailable",reason:e.error};const a=e.data.store_health;return a===void 0?{status:"unavailable",reason:"supervisor did not report store_health"}:{status:"available",value:a,source:"supervisor status.store_health",...e.staleReason!==null?{stale:I(e.staleReason)}:{}}}function Ce(e){return e===null?{status:"unavailable",reason:"supervisor status still loading"}:e.status==="unavailable"?{status:"unavailable",reason:e.error}:{status:"available",value:e.data.work,source:"supervisor status.work",...e.staleReason!==null?{stale:I(e.staleReason)}:{}}}function Le(e){const a=K(e);if(a.status==="unavailable")return{status:"unavailable",reason:a.reason};const s=a.value;return{status:"available",source:"supervisor status.store_health (threshold vs actual)",...a.stale!==void 0?{stale:a.stale}:{},value:[{label:"Dolt MB-per-row ratio",recommended:`<= ${s.threshold_mb_per_row}`,loaded:String(s.ratio_mb_per_row),withinRecommendation:!s.warning}]}}function Te(e){return typeof e=="bigint"?Number(e):e}function j(e){if(e<60)return`${e}s`;if(e<3600)return`${Math.round(e/60)}m`;if(e<86400)return`${Math.round(e/3600)}h`;const a=Math.floor(e/86400),s=Math.round(e%86400/3600);return s>0?`${a}d ${s}h`:`${a}d`}export{Ee as HealthPage}; diff --git a/internal/api/dashboardspa/dist/assets/Health-Dtx4mLLZ.js b/internal/api/dashboardspa/dist/assets/Health-Dtx4mLLZ.js new file mode 100644 index 0000000000..8805b56092 --- /dev/null +++ b/internal/api/dashboardspa/dist/assets/Health-Dtx4mLLZ.js @@ -0,0 +1 @@ +import{a as Y,b as v,r as Z,j as t,B as ee,W as y,z as E,S as V,F,aa as te}from"./index-CJ6RRl2D.js";import{p as $,d as ae}from"./routeHighlight-B30gQO2o.js";import{P as se}from"./PageHeader-4s_Bnmfl.js";import{u as le}from"./useVisibleRefresh-DJ0jEjH6.js";import{a as x}from"./format-fte2CeYD.js";import{a as ne}from"./time-D9v0saHV.js";const re=2500;function Ee(){const e=Y(),a=F(),s=v("health:system",ye),o=v(`health:supervisor:${a??"no-city"}`,_e),r=v(`health:status:${a??"no-city"}`,we),c=v("health:local-tools",Ne),b=v(`health:dolt-noms-trend:${a??"no-city"}`,Se),p=v(`health:rig-store:${a??"no-city"}`,ke),d=s.refresh,_=o.refresh,w=r.refresh,N=c.refresh,C=b.refresh,L=p.refresh,K=s.loading||o.loading||r.loading||c.loading||b.loading||p.loading,T=[s.error,o.error,r.error,c.error,b.error,p.error].filter(J=>J!==null).join("; ")||null,D=Z.useCallback(async()=>{await Promise.all([d(),_(),w(),N(),C(),L()])},[C,N,L,_,w,d]),m=s.data??null,n=m?.status==="available"?m.data:null,S=m?.status==="unavailable"?m.error:null,i=o.data??null,k=r.data??null,U=c.data??null,u=b.data??null,f=p.data??null,B=f?pe(f):void 0,R=m!==null||i!==null||k!==null||U!==null||u!==null||f!==null,M=n?He(n):void 0,q=$(e,"health",["health:supervisor-"]),Q=$(e,"health",["health:load-","health:memory-"]),X=$(e,"health",["health:dashboard-"]),G=$(e,"health",["health:dolt-noms-"]);return le(D,3e4),t.jsxs("section",{children:[t.jsx(se,{title:"Health",synopsis:R?$e(n,i):"Reading state from the supervisor.",meta:t.jsxs(t.Fragment,{children:[T&&t.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:T}),t.jsx(ee,{size:"sm",onClick:()=>{D()},children:K&&!R?"Loading":"Refresh"})]})}),R?t.jsxs("div",{className:"space-y-12",children:[t.jsx(h,{title:"Supervisor",attention:q,...i?{status:Re(i)}:{},children:i===null?t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading supervisor state."}):i.status==="available"?t.jsxs(g,{children:[i.data.city!==void 0?t.jsx(l,{label:"City",value:i.data.city}):t.jsx(l,{label:"City",value:"not reported by supervisor",tone:"warn"}),i.data.version!==void 0?t.jsx(l,{label:"Version",value:i.data.version}):t.jsx(l,{label:"Version",value:"not reported by supervisor",tone:"warn"}),t.jsx(l,{label:"Uptime",value:j(i.data.uptime_sec)}),t.jsx(l,{label:"Status",value:i.data.status})]}):t.jsx("p",{className:"text-body text-accent",children:"Supervisor not reachable. The dashboard shell stays up; live data is stale."})}),t.jsx(h,{title:"Host",attention:Q,...M?{status:M}:{},children:m===null?t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading dashboard host health."}):n===null?t.jsxs("p",{className:"text-body text-accent",children:["Dashboard host health unavailable",S?`: ${S}`:"","."]}):t.jsxs(g,{children:[t.jsx(l,{label:"CPUs",value:n.host.cpu_count.toString()}),t.jsx(l,{label:"Load (1m, 5m, 15m)",value:`${n.host.load_avg_1.toFixed(2)}, ${n.host.load_avg_5.toFixed(2)}, ${n.host.load_avg_15.toFixed(2)}`,...n.host.load_avg_1>n.host.cpu_count?{tone:"warn"}:{}}),t.jsx(l,{label:"Memory free",value:`${x(n.host.free_mem_bytes)} of ${x(n.host.total_mem_bytes)}`,...n.host.free_mem_bytes/n.host.total_mem_bytes<.1?{tone:"warn"}:{}}),t.jsx(l,{label:"Host uptime",value:j(n.host.uptime_sec)})]})}),t.jsx(h,{title:"Admin process",attention:X,children:m===null?t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading dashboard process health."}):n===null?t.jsxs("p",{className:"text-body text-accent",children:["Dashboard process health unavailable",S?`: ${S}`:"","."]}):t.jsxs(g,{children:[t.jsx(l,{label:"PID",value:n.admin.pid.toString()}),t.jsx(l,{label:"Uptime",value:j(n.admin.uptime_sec)}),t.jsx(l,{label:"RSS",value:x(n.admin.rss_bytes)}),t.jsx(l,{label:"Heap used",value:x(n.admin.heap_used_bytes)}),t.jsx(l,{label:"Node",value:n.admin.node_version})]})}),t.jsx(h,{title:"Tool versions",children:t.jsx(oe,{state:U})}),t.jsx(h,{title:"Diagnostics",children:t.jsxs("div",{className:"space-y-8",children:[t.jsx(ce,{usage:W(k)}),t.jsx(ue,{usage:Ce(k)})]})}),t.jsx(h,{title:"Bead stores · per rig",meta:be(f),...B?{status:B}:{},children:t.jsx(de,{report:f})}),t.jsx(h,{title:"Store thresholds",children:t.jsx(ve,{comparison:Le(k)})}),t.jsx(h,{title:"Dolt-noms · 24 h",attention:G,meta:u&&u.samples.length>0?`${u.samples.length} samples`:void 0,children:u===null?t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading."}):u.available?u.samples.length===0?t.jsx("p",{className:"text-body text-fg-muted italic",children:"No samples yet. Backend just started; next sample in ten minutes or less."}):t.jsx(ge,{samples:u.samples}):t.jsxs("p",{className:"text-body text-fg-muted italic",children:["Dolt-noms metric unavailable: ",je(u.reason),"."]})})]}):t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading."})]})}function h({title:e,status:a,meta:s,attention:o,children:r}){return t.jsxs("section",{...ae(o??null),children:[t.jsxs("header",{className:"flex items-baseline justify-between gap-4 mb-4 pb-2 border-b border-rule",children:[t.jsx("h2",{className:"text-headline font-semibold text-fg",children:e}),t.jsxs("div",{className:"flex items-baseline gap-4",children:[s&&t.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:s}),a&&t.jsx(V,{tone:a.tone,label:a.label})]})]}),r]})}function g({children:e}){return t.jsx("dl",{className:"grid grid-cols-[max-content_1fr] gap-x-8 gap-y-3 max-w-prose",children:e})}function l({label:e,value:a,tone:s}){const o=s==="warn"?"text-warn":s==="stuck"?"text-accent":"text-fg";return t.jsxs(t.Fragment,{children:[t.jsx("dt",{className:"text-body text-fg-muted",children:e}),t.jsx("dd",{className:`text-body tnum font-medium ${o}`,children:a})]})}function oe({state:e}){if(e===null)return t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading tool versions."});if(e.status==="unavailable")return t.jsxs("p",{className:"text-body text-fg-muted italic",children:["Tool versions unavailable: ",e.error,"."]});const a=[{label:"gc",tool:e.data.gc},{label:"bd",tool:e.data.beads},{label:"dolt",tool:e.data.dolt}];return t.jsxs("div",{className:"grid grid-cols-[1fr_max-content] gap-x-8 gap-y-3 max-w-prose",children:[t.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Tool"}),t.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted text-right",children:"Installed"}),a.map(s=>t.jsx(ie,{label:s.label,tool:s.tool},s.label))]})}function ie({label:e,tool:a}){return t.jsxs("div",{className:"contents","data-tool-version-row":e,children:[t.jsx("div",{className:"text-body text-fg",children:e}),t.jsx("div",{className:"text-right",children:a.status==="available"?t.jsx("span",{className:"text-body tnum font-medium text-fg",children:a.version}):t.jsxs("div",{className:"space-y-1",children:[t.jsx("div",{className:"text-body tnum font-medium text-warn",children:"unavailable"}),t.jsx("div",{className:"text-label text-fg-muted normal-case",children:a.reason})]})})]})}function ce({usage:e}){if(e.status==="unavailable")return t.jsx(O,{heading:"Dolt usage",reason:e.reason});const a=e.value;return t.jsxs("div",{className:"space-y-2",children:[t.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Dolt usage"}),e.stale!==void 0&&t.jsx(H,{message:e.stale}),t.jsxs(g,{children:[t.jsx(l,{label:"On-disk size",value:x(Te(a.size_bytes))}),t.jsx(l,{label:"Live rows",value:a.live_rows.toLocaleString()}),t.jsx(l,{label:"MB per row",value:a.ratio_mb_per_row.toString()}),t.jsx(l,{label:"Last maintenance",value:a.last_gc_status??"not reported",...a.last_gc_status!==void 0&&a.last_gc_status!=="success"?{tone:"warn"}:{}}),a.last_gc_at!==void 0&&t.jsx(l,{label:"Last maintenance at",value:ne(a.last_gc_at)}),t.jsx(l,{label:"Store path",value:a.path})]})]})}function ue({usage:e}){if(e.status==="unavailable")return t.jsx(O,{heading:"Beads usage",reason:e.reason});const a=e.value;return t.jsxs("div",{className:"space-y-2",children:[t.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Beads usage"}),e.stale!==void 0&&t.jsx(H,{message:e.stale}),t.jsxs(g,{children:[t.jsx(l,{label:"Open",value:a.open.toString()}),t.jsx(l,{label:"Ready",value:a.ready.toString()}),t.jsx(l,{label:"In progress",value:a.in_progress.toString()})]})]})}function de({report:e}){if(e===null)return t.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading per-rig store health."});if(!e.available&&e.rigs.length===0)return t.jsxs("p",{className:"text-body text-fg-muted italic",children:["Per-rig store health unavailable: ",P(e.reason),"."]});const a=[...e.rigs].sort((s,o)=>A(o.rollup)-A(s.rollup));return t.jsxs("div",{className:"space-y-6 max-w-prose",children:[!e.available&&t.jsxs("p",{className:"text-body text-warn italic",children:["Showing the last sample; refresh failed: ",P(e.reason),"."]}),a.map(s=>t.jsx(he,{rig:s},s.rig))]})}function he({rig:e}){const a=xe(e);return t.jsxs("div",{className:"space-y-2 border-b border-rule pb-4 last:border-b-0",children:[t.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[t.jsx("span",{className:"text-body font-medium text-fg",children:e.rig}),t.jsx(V,{tone:a.tone,label:a.label})]}),t.jsxs("dl",{className:"grid grid-cols-[max-content_1fr] gap-x-6 gap-y-1",children:[t.jsx(l,{label:"Dolt server",value:me(e),...e.doltConnected===!1?{tone:"stuck"}:{}}),e.issueCount!==null&&t.jsx(l,{label:"Live issues",value:e.issueCount.toLocaleString()})]}),e.problems.length>0&&t.jsx("ul",{className:"space-y-1",children:e.problems.map(s=>t.jsxs("li",{className:`text-label ${s.status==="error"?"text-accent":"text-warn"}`,children:[s.name,": ",s.message]},`${s.category}/${s.name}`))}),e.note!==void 0&&t.jsx("p",{className:"text-label text-fg-muted italic",children:e.note})]})}function me(e){const a=e.doltEndpoint??"no endpoint reported";return e.doltConnected===!0?`up · ${a}`:e.doltConnected===!1?`DOWN · ${a}`:`unknown · ${a}`}function xe(e){switch(e.rollup){case"ok":return{tone:"ok",label:"healthy"};case"warn":return{tone:"warn",label:"warnings"};case"down":return e.reachable?e.doltConnected===!1?{tone:"stuck",label:"dolt down"}:{tone:"stuck",label:"errors"}:{tone:"stuck",label:"unreachable"}}}function A(e){return e==="down"?2:e==="warn"?1:0}function be(e){if(e===null||e.rigs.length===0)return;const a={ok:0,warn:0,down:0};for(const s of e.rigs)a[s.rollup]+=1;return`${a.ok} ok · ${a.warn} warn · ${a.down} down`}function pe(e){if(e.rigs.some(a=>a.rollup==="down"))return{tone:"stuck",label:"attention"};if(e.rigs.some(a=>a.rollup==="warn"))return{tone:"warn",label:"warnings"};if(e.rigs.length>0)return{tone:"ok",label:"healthy"}}function P(e){switch(e){case"not_sampled_yet":return"backend just started; first sample is in flight";case"rig_list_failed":return"the supervisor rig list could not be read";case"fetch_failed":return"the dashboard backend could not be reached"}}function ve({comparison:e}){return e.status==="unavailable"?t.jsxs("p",{className:"text-body text-fg-muted italic",children:["Comparison unavailable: ",e.reason,"."]}):t.jsxs("div",{className:"space-y-2",children:[e.stale!==void 0&&t.jsx(H,{message:e.stale}),t.jsxs("div",{className:"grid grid-cols-[1fr_max-content_max-content] gap-x-8 gap-y-3 max-w-prose",children:[t.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Setting"}),t.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted text-right",children:"Recommended"}),t.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted text-right",children:"Loaded"}),e.value.map(a=>t.jsx(fe,{row:a},a.label))]})]})}function fe({row:e}){const a=e.withinRecommendation?"text-fg":"text-warn";return t.jsxs("div",{className:`contents ${a}`,"data-comparison-row":e.label,children:[t.jsxs("div",{className:`text-body ${a}`,children:[e.label,!e.withinRecommendation&&t.jsx("span",{className:"text-label uppercase tracking-wider text-warn",children:" · over"})]}),t.jsx("div",{className:"text-body tnum text-fg-muted text-right",children:e.recommended}),t.jsx("div",{className:`text-body tnum font-medium text-right ${a}`,children:e.loaded})]})}function O({heading:e,reason:a}){return t.jsxs("div",{className:"space-y-2",children:[t.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-muted",children:e}),t.jsxs("p",{className:"text-body text-fg-muted italic",children:["Unavailable: ",a,"."]})]})}function H({message:e}){return t.jsx("p",{className:"text-body text-warn italic",children:e})}function ge({samples:e}){if(e.length===0)return null;const a=Math.max(...e.map(d=>d.bytes)),s=Math.min(...e.map(d=>d.bytes)),o=a-s||1,r=600,c=60,b=e.length>1?r/(e.length-1):r,p=e.map((d,_)=>{const w=_*b,N=c-(d.bytes-s)/o*c;return`${w.toFixed(1)},${N.toFixed(1)}`}).join(" ");return t.jsxs("div",{className:"space-y-3 max-w-prose",children:[t.jsx("svg",{viewBox:`0 0 ${r} ${c}`,preserveAspectRatio:"none",className:"w-full h-16","aria-label":"24 hour dolt-noms size trend",children:t.jsx("polyline",{fill:"none",stroke:"currentColor",strokeWidth:"1",className:"text-accent",points:p})}),t.jsxs("div",{className:"flex items-baseline justify-between text-label uppercase tracking-wider text-fg-muted tnum",children:[t.jsxs("span",{children:["min ",x(s)]}),t.jsxs("span",{children:["max ",x(a)]})]})]})}function je(e){switch(e){case"store_health_absent":return"supervisor is not reporting store_health; samples resume when it recovers";case"sample_failed":return"latest supervisor status read failed; check the backend log"}}async function ye(){try{return{status:"available",data:await y.systemHealth()}}catch(e){return{status:"unavailable",error:E(e,"dashboard host health unavailable")}}}async function _e(){const e=F();if(e===null)throw new Error("Health page loaded before an active city was resolved");try{return{status:"available",data:await te(re).cityHealth(e)}}catch{return{status:"unavailable",error:"supervisor health unavailable"}}}function z(e){switch(e){case"not_sampled_yet":return"supervisor status sample is warming up; data appears after the next backend sample";case"status_read_failed":return"latest supervisor status read failed; check the backend log"}}function I(e){return`Showing the last sample; refresh failed: ${z(e)}.`}async function we(){try{const e=await y.supervisorStatus();return e.available?{status:"available",data:e.status,staleReason:null}:e.status!==null?{status:"available",data:e.status,staleReason:e.reason}:{status:"unavailable",error:z(e.reason)}}catch(e){return{status:"unavailable",error:E(e,"supervisor status unavailable")}}}async function Ne(){try{return{status:"available",data:await y.localToolVersions()}}catch{return{status:"unavailable",error:"local tool versions unavailable"}}}async function Se(){try{return await y.doltTrend()}catch{return{available:!1,reason:"sample_failed",samples:[]}}}async function ke(){try{return await y.rigStoreHealth()}catch{return{available:!1,reason:"fetch_failed",rigs:[]}}}function $e(e,a){const s=[];if(a===null)s.push("Supervisor state still loading.");else if(a.status==="available"){const r=a.data,c=r.status==="ok"?"healthy":r.status;r.city!==void 0?s.push(`Supervisor ${c} on ${r.city}, uptime ${j(r.uptime_sec)}.`):s.push(`Supervisor ${c}, uptime ${j(r.uptime_sec)}.`)}else s.push("Supervisor unreachable.");if(e===null)return s.push("Host health unavailable."),s.join(" ");const o=Math.round(100*(1-e.host.free_mem_bytes/e.host.total_mem_bytes));return s.push(`Memory at ${o}%; ${e.host.cpu_count} CPUs averaging ${e.host.load_avg_1.toFixed(2)} load.`),s.join(" ")}function Re(e){return e.status==="unavailable"?{tone:"stuck",label:"offline"}:e.data.status==="ok"?{tone:"ok",label:"healthy"}:{tone:"warn",label:e.data.status}}function He(e){const a=e.host.free_mem_bytes/e.host.total_mem_bytes;if(a<.05)return{tone:"stuck",label:"memory critical"};if(a<.1)return{tone:"warn",label:"memory low"};if(e.host.load_avg_1>e.host.cpu_count*1.5)return{tone:"warn",label:"load high"}}function W(e){if(e===null)return{status:"unavailable",reason:"supervisor status still loading"};if(e.status==="unavailable")return{status:"unavailable",reason:e.error};const a=e.data.store_health;return a===void 0?{status:"unavailable",reason:"supervisor did not report store_health"}:{status:"available",value:a,source:"supervisor status.store_health",...e.staleReason!==null?{stale:I(e.staleReason)}:{}}}function Ce(e){return e===null?{status:"unavailable",reason:"supervisor status still loading"}:e.status==="unavailable"?{status:"unavailable",reason:e.error}:{status:"available",value:e.data.work,source:"supervisor status.work",...e.staleReason!==null?{stale:I(e.staleReason)}:{}}}function Le(e){const a=W(e);if(a.status==="unavailable")return{status:"unavailable",reason:a.reason};const s=a.value;return{status:"available",source:"supervisor status.store_health (threshold vs actual)",...a.stale!==void 0?{stale:a.stale}:{},value:[{label:"Dolt MB-per-row ratio",recommended:`<= ${s.threshold_mb_per_row}`,loaded:String(s.ratio_mb_per_row),withinRecommendation:!s.warning}]}}function Te(e){return typeof e=="bigint"?Number(e):e}function j(e){if(e<60)return`${e}s`;if(e<3600)return`${Math.round(e/60)}m`;if(e<86400)return`${Math.round(e/3600)}h`;const a=Math.floor(e/86400),s=Math.round(e%86400/3600);return s>0?`${a}d ${s}h`:`${a}d`}export{Ee as HealthPage}; diff --git a/internal/api/dashboardspa/dist/assets/LiveSessionPeek-jm19JJ4Z.js b/internal/api/dashboardspa/dist/assets/LiveSessionPeek-D86UU9cf.js similarity index 73% rename from internal/api/dashboardspa/dist/assets/LiveSessionPeek-jm19JJ4Z.js rename to internal/api/dashboardspa/dist/assets/LiveSessionPeek-D86UU9cf.js index baa8c7aedd..111b30c8f9 100644 --- a/internal/api/dashboardspa/dist/assets/LiveSessionPeek-jm19JJ4Z.js +++ b/internal/api/dashboardspa/dist/assets/LiveSessionPeek-D86UU9cf.js @@ -1,4 +1,4 @@ -import{r as d,a4 as O,I,p as C,x as L,a5 as A,H as $,j as l,S as B}from"./index-C20tCZFz.js";import{a as M,b as U,f as v}from"./time-D9v0saHV.js";import{a as D}from"./format-fte2CeYD.js";import{P as F}from"./constants-DBKWGg29.js";function q(t,e){const[s,r]=d.useState({status:"idle",stream:{status:"idle"}}),n=d.useRef(!1);return d.useEffect(()=>{if(n.current=!1,!t){r({status:"idle",stream:{status:"idle"}});return}let i=!1,a=null;const u=e&&typeof EventSource<"u";return r({status:"loading",stream:{status:u?"connecting":"idle"}}),O(t).then(c=>{if(!i&&(r({status:"ready",result:c,stream:{status:u?"connecting":"idle"}}),u)){a=new EventSource(I().sessionStreamUrl(G("open supervisor session stream"),t),{withCredentials:!0}),a.onopen=()=>{i||r(p=>p.status==="ready"?{...p,stream:{status:"open"}}:p)};const f=p=>{if(i)return;const h=z(p.data);h.kind==="invalid"&&H(t,n),r(_=>{const m=_.status==="ready"?_.result:c;return h.kind==="invalid"?{status:"ready",result:m,stream:{status:"degraded",error:h.error}}:h.kind==="snapshot"?{status:"ready",result:h.result,stream:{status:"open"}}:{status:"ready",result:{...m,turns:[...m.turns,h.turn],total_chars:m.total_chars+h.turn.text.length,captured_at:new Date().toISOString()},stream:{status:"open"}}})};a.onmessage=f,a.addEventListener("turn",f),a.onerror=()=>{if(i)return;const p=a?.readyState===EventSource.CLOSED?"closed":"connecting";r(h=>h.status==="ready"?{...h,stream:{status:p}}:h)}}},c=>{i||(N("load transcript",t,c),r({status:"failed",error:C(c)||"Failed to load session.",stream:{status:"idle"}}))}),()=>{i=!0,a?.close()}},[t,e]),s}function H(t,e){e.current||(e.current=!0,N("parse stream event",t,b))}function N(t,e,s){L({component:"session-stream",operation:t,message:`${e}: ${C(s)}`})}function G(t){const e=$();if(e===null)throw new Error(`${t} called before an active city was resolved`);return e}const b="Malformed session stream event.";function z(t){let e;try{e=JSON.parse(t)}catch{return{kind:"invalid",error:b}}if(!T(e))return{kind:"invalid",error:b};const s=V(e);return s?{kind:"snapshot",result:s}:typeof e.text!="string"?{kind:"invalid",error:b}:{kind:"turn",turn:{role:typeof e.role=="string"?e.role:"assistant",text:e.text}}}function V(t){if(!Array.isArray(t.turns))return null;const e=t.turns.flatMap(i=>!T(i)||typeof i.text!="string"?[]:[{role:typeof i.role=="string"?i.role:"assistant",text:i.text}]);if(e.length!==t.turns.length)return null;const s=typeof t.session_id=="string"?t.session_id:typeof t.id=="string"?t.id:"";if(!s)return null;const r=typeof t.total_chars=="number"?t.total_chars:e.reduce((i,a)=>i+a.text.length,0);return{...A({id:s,template:typeof t.template=="string"?t.template:"",provider:typeof t.provider=="string"?t.provider:"",format:typeof t.format=="string"?t.format:"conversation",turns:e},typeof t.captured_at=="string"?t.captured_at:new Date().toISOString()),total_chars:r,truncated:t.truncated===!0}}function T(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)}var S=function(t,e){return Object.defineProperty?Object.defineProperty(t,"raw",{value:e}):t.raw=e,t},o;(function(t){t[t.EOS=0]="EOS",t[t.Text=1]="Text",t[t.Incomplete=2]="Incomplete",t[t.ESC=3]="ESC",t[t.Unknown=4]="Unknown",t[t.SGR=5]="SGR",t[t.OSCURL=6]="OSCURL"})(o||(o={}));class P{constructor(){this.VERSION="6.0.6",this.setup_palettes(),this._use_classes=!1,this.bold=!1,this.faint=!1,this.italic=!1,this.underline=!1,this.fg=this.bg=null,this._buffer="",this._url_allowlist={http:1,https:1},this._escape_html=!0,this.boldStyle="font-weight:bold",this.faintStyle="opacity:0.7",this.italicStyle="font-style:italic",this.underlineStyle="text-decoration:underline"}set use_classes(e){this._use_classes=e}get use_classes(){return this._use_classes}set url_allowlist(e){this._url_allowlist=e}get url_allowlist(){return this._url_allowlist}set escape_html(e){this._escape_html=e}get escape_html(){return this._escape_html}set boldStyle(e){this._boldStyle=e}get boldStyle(){return this._boldStyle}set faintStyle(e){this._faintStyle=e}get faintStyle(){return this._faintStyle}set italicStyle(e){this._italicStyle=e}get italicStyle(){return this._italicStyle}set underlineStyle(e){this._underlineStyle=e}get underlineStyle(){return this._underlineStyle}setup_palettes(){this.ansi_colors=[[{rgb:[0,0,0],class_name:"ansi-black"},{rgb:[187,0,0],class_name:"ansi-red"},{rgb:[0,187,0],class_name:"ansi-green"},{rgb:[187,187,0],class_name:"ansi-yellow"},{rgb:[0,0,187],class_name:"ansi-blue"},{rgb:[187,0,187],class_name:"ansi-magenta"},{rgb:[0,187,187],class_name:"ansi-cyan"},{rgb:[255,255,255],class_name:"ansi-white"}],[{rgb:[85,85,85],class_name:"ansi-bright-black"},{rgb:[255,85,85],class_name:"ansi-bright-red"},{rgb:[0,255,0],class_name:"ansi-bright-green"},{rgb:[255,255,85],class_name:"ansi-bright-yellow"},{rgb:[85,85,255],class_name:"ansi-bright-blue"},{rgb:[255,85,255],class_name:"ansi-bright-magenta"},{rgb:[85,255,255],class_name:"ansi-bright-cyan"},{rgb:[255,255,255],class_name:"ansi-bright-white"}]],this.palette_256=[],this.ansi_colors.forEach(r=>{r.forEach(n=>{this.palette_256.push(n)})});let e=[0,95,135,175,215,255];for(let r=0;r<6;++r)for(let n=0;n<6;++n)for(let i=0;i<6;++i){let a={rgb:[e[r],e[n],e[i]],class_name:"truecolor"};this.palette_256.push(a)}let s=8;for(let r=0;r<24;++r,s+=10){let n={rgb:[s,s,s],class_name:"truecolor"};this.palette_256.push(n)}}escape_txt_for_html(e){return this._escape_html?e.replace(/[&<>"']/gm,s=>{if(s==="&")return"&";if(s==="<")return"<";if(s===">")return">";if(s==='"')return""";if(s==="'")return"'"}):e}append_buffer(e){var s=this._buffer+e;this._buffer=s}get_next_packet(){var e={kind:o.EOS,text:"",url:""},s=this._buffer.length;if(s==0)return e;var r=this._buffer.indexOf("\x1B");if(r==-1)return e.kind=o.Text,e.text=this._buffer,this._buffer="",e;if(r>0)return e.kind=o.Text,e.text=this._buffer.slice(0,r),this._buffer=this._buffer.slice(r),e;if(r==0){if(s<3)return e.kind=o.Incomplete,e;var n=this._buffer.charAt(1);if(n!="["&&n!="]"&&n!="(")return e.kind=o.ESC,e.text=this._buffer.slice(0,1),this._buffer=this._buffer.slice(1),e;if(n=="["){this._csi_regex||(this._csi_regex=y(w||(w=S([` +import{r as d,a3 as O,H as I,p as C,x as L,a4 as A,F as $,j as l,S as B}from"./index-CJ6RRl2D.js";import{a as M,b as U,f as v}from"./time-D9v0saHV.js";import{a as F}from"./format-fte2CeYD.js";import{P as D}from"./constants-BJUwiA6r.js";function q(t,e){const[s,r]=d.useState({status:"idle",stream:{status:"idle"}}),n=d.useRef(!1);return d.useEffect(()=>{if(n.current=!1,!t){r({status:"idle",stream:{status:"idle"}});return}let i=!1,a=null;const u=e&&typeof EventSource<"u";return r({status:"loading",stream:{status:u?"connecting":"idle"}}),O(t).then(c=>{if(!i&&(r({status:"ready",result:c,stream:{status:u?"connecting":"idle"}}),u)){a=new EventSource(I().sessionStreamUrl(G("open supervisor session stream"),t),{withCredentials:!0}),a.onopen=()=>{i||r(p=>p.status==="ready"?{...p,stream:{status:"open"}}:p)};const f=p=>{if(i)return;const h=z(p.data);h.kind==="invalid"&&H(t,n),r(_=>{const m=_.status==="ready"?_.result:c;return h.kind==="invalid"?{status:"ready",result:m,stream:{status:"degraded",error:h.error}}:h.kind==="snapshot"?{status:"ready",result:h.result,stream:{status:"open"}}:{status:"ready",result:{...m,turns:[...m.turns,h.turn],total_chars:m.total_chars+h.turn.text.length,captured_at:new Date().toISOString()},stream:{status:"open"}}})};a.onmessage=f,a.addEventListener("turn",f),a.onerror=()=>{if(i)return;const p=a?.readyState===EventSource.CLOSED?"closed":"connecting";r(h=>h.status==="ready"?{...h,stream:{status:p}}:h)}}},c=>{i||(N("load transcript",t,c),r({status:"failed",error:C(c)||"Failed to load session.",stream:{status:"idle"}}))}),()=>{i=!0,a?.close()}},[t,e]),s}function H(t,e){e.current||(e.current=!0,N("parse stream event",t,b))}function N(t,e,s){L({component:"session-stream",operation:t,message:`${e}: ${C(s)}`})}function G(t){const e=$();if(e===null)throw new Error(`${t} called before an active city was resolved`);return e}const b="Malformed session stream event.";function z(t){let e;try{e=JSON.parse(t)}catch{return{kind:"invalid",error:b}}if(!T(e))return{kind:"invalid",error:b};const s=V(e);return s?{kind:"snapshot",result:s}:typeof e.text!="string"?{kind:"invalid",error:b}:{kind:"turn",turn:{role:typeof e.role=="string"?e.role:"assistant",text:e.text}}}function V(t){if(!Array.isArray(t.turns))return null;const e=t.turns.flatMap(i=>!T(i)||typeof i.text!="string"?[]:[{role:typeof i.role=="string"?i.role:"assistant",text:i.text}]);if(e.length!==t.turns.length)return null;const s=typeof t.session_id=="string"?t.session_id:typeof t.id=="string"?t.id:"";if(!s)return null;const r=typeof t.total_chars=="number"?t.total_chars:e.reduce((i,a)=>i+a.text.length,0);return{...A({id:s,template:typeof t.template=="string"?t.template:"",provider:typeof t.provider=="string"?t.provider:"",format:typeof t.format=="string"?t.format:"conversation",turns:e},typeof t.captured_at=="string"?t.captured_at:new Date().toISOString()),total_chars:r,truncated:t.truncated===!0}}function T(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)}var S=function(t,e){return Object.defineProperty?Object.defineProperty(t,"raw",{value:e}):t.raw=e,t},o;(function(t){t[t.EOS=0]="EOS",t[t.Text=1]="Text",t[t.Incomplete=2]="Incomplete",t[t.ESC=3]="ESC",t[t.Unknown=4]="Unknown",t[t.SGR=5]="SGR",t[t.OSCURL=6]="OSCURL"})(o||(o={}));class P{constructor(){this.VERSION="6.0.6",this.setup_palettes(),this._use_classes=!1,this.bold=!1,this.faint=!1,this.italic=!1,this.underline=!1,this.fg=this.bg=null,this._buffer="",this._url_allowlist={http:1,https:1},this._escape_html=!0,this.boldStyle="font-weight:bold",this.faintStyle="opacity:0.7",this.italicStyle="font-style:italic",this.underlineStyle="text-decoration:underline"}set use_classes(e){this._use_classes=e}get use_classes(){return this._use_classes}set url_allowlist(e){this._url_allowlist=e}get url_allowlist(){return this._url_allowlist}set escape_html(e){this._escape_html=e}get escape_html(){return this._escape_html}set boldStyle(e){this._boldStyle=e}get boldStyle(){return this._boldStyle}set faintStyle(e){this._faintStyle=e}get faintStyle(){return this._faintStyle}set italicStyle(e){this._italicStyle=e}get italicStyle(){return this._italicStyle}set underlineStyle(e){this._underlineStyle=e}get underlineStyle(){return this._underlineStyle}setup_palettes(){this.ansi_colors=[[{rgb:[0,0,0],class_name:"ansi-black"},{rgb:[187,0,0],class_name:"ansi-red"},{rgb:[0,187,0],class_name:"ansi-green"},{rgb:[187,187,0],class_name:"ansi-yellow"},{rgb:[0,0,187],class_name:"ansi-blue"},{rgb:[187,0,187],class_name:"ansi-magenta"},{rgb:[0,187,187],class_name:"ansi-cyan"},{rgb:[255,255,255],class_name:"ansi-white"}],[{rgb:[85,85,85],class_name:"ansi-bright-black"},{rgb:[255,85,85],class_name:"ansi-bright-red"},{rgb:[0,255,0],class_name:"ansi-bright-green"},{rgb:[255,255,85],class_name:"ansi-bright-yellow"},{rgb:[85,85,255],class_name:"ansi-bright-blue"},{rgb:[255,85,255],class_name:"ansi-bright-magenta"},{rgb:[85,255,255],class_name:"ansi-bright-cyan"},{rgb:[255,255,255],class_name:"ansi-bright-white"}]],this.palette_256=[],this.ansi_colors.forEach(r=>{r.forEach(n=>{this.palette_256.push(n)})});let e=[0,95,135,175,215,255];for(let r=0;r<6;++r)for(let n=0;n<6;++n)for(let i=0;i<6;++i){let a={rgb:[e[r],e[n],e[i]],class_name:"truecolor"};this.palette_256.push(a)}let s=8;for(let r=0;r<24;++r,s+=10){let n={rgb:[s,s,s],class_name:"truecolor"};this.palette_256.push(n)}}escape_txt_for_html(e){return this._escape_html?e.replace(/[&<>"']/gm,s=>{if(s==="&")return"&";if(s==="<")return"<";if(s===">")return">";if(s==='"')return""";if(s==="'")return"'"}):e}append_buffer(e){var s=this._buffer+e;this._buffer=s}get_next_packet(){var e={kind:o.EOS,text:"",url:""},s=this._buffer.length;if(s==0)return e;var r=this._buffer.indexOf("\x1B");if(r==-1)return e.kind=o.Text,e.text=this._buffer,this._buffer="",e;if(r>0)return e.kind=o.Text,e.text=this._buffer.slice(0,r),this._buffer=this._buffer.slice(r),e;if(r==0){if(s<3)return e.kind=o.Incomplete,e;var n=this._buffer.charAt(1);if(n!="["&&n!="]"&&n!="(")return e.kind=o.ESC,e.text=this._buffer.slice(0,1),this._buffer=this._buffer.slice(1),e;if(n=="["){this._csi_regex||(this._csi_regex=y(w||(w=S([` ^ # beginning of line # # First attempt @@ -95,4 +95,4 @@ import{r as d,a4 as O,I,p as C,x as L,a5 as A,H as $,j as l,S as B}from"./index- | # alternate (?:\\x07) # BEL (what xterm did) ) - `]))));let a=this._buffer.match(this._osc_regex);if(a===null)return e.kind=o.ESC,e.text=this._buffer.slice(0,1),this._buffer=this._buffer.slice(1),e;e.kind=o.OSCURL,e.url=a[1],e.text=a[2];var i=a[0].length;return this._buffer=this._buffer.slice(i),e}else if(n=="(")return e.kind=o.Unknown,this._buffer=this._buffer.slice(3),e}}ansi_to_html(e){this.append_buffer(e);for(var s=[];;){var r=this.get_next_packet();if(r.kind==o.EOS||r.kind==o.Incomplete)break;r.kind==o.ESC||r.kind==o.Unknown||(r.kind==o.Text?s.push(this.transform_to_html(this.with_state(r))):r.kind==o.SGR?this.process_ansi(r):r.kind==o.OSCURL&&s.push(this.process_hyperlink(r)))}return s.join("")}with_state(e){return{bold:this.bold,faint:this.faint,italic:this.italic,underline:this.underline,fg:this.fg,bg:this.bg,text:e.text}}process_ansi(e){let s=e.text.split(";");for(;s.length>0;){let r=s.shift(),n=parseInt(r,10);if(isNaN(n)||n===0)this.fg=null,this.bg=null,this.bold=!1,this.faint=!1,this.italic=!1,this.underline=!1;else if(n===1)this.bold=!0;else if(n===2)this.faint=!0;else if(n===3)this.italic=!0;else if(n===4)this.underline=!0;else if(n===21)this.bold=!1;else if(n===22)this.faint=!1,this.bold=!1;else if(n===23)this.italic=!1;else if(n===24)this.underline=!1;else if(n===39)this.fg=null;else if(n===49)this.bg=null;else if(n>=30&&n<38)this.fg=this.ansi_colors[0][n-30];else if(n>=40&&n<48)this.bg=this.ansi_colors[0][n-40];else if(n>=90&&n<98)this.fg=this.ansi_colors[1][n-90];else if(n>=100&&n<108)this.bg=this.ansi_colors[1][n-100];else if((n===38||n===48)&&s.length>0){let i=n===38,a=s.shift();if(a==="5"&&s.length>0){let u=parseInt(s.shift(),10);u>=0&&u<=255&&(i?this.fg=this.palette_256[u]:this.bg=this.palette_256[u])}if(a==="2"&&s.length>2){let u=parseInt(s.shift(),10),c=parseInt(s.shift(),10),f=parseInt(s.shift(),10);if(u>=0&&u<=255&&c>=0&&c<=255&&f>=0&&f<=255){let p={rgb:[u,c,f],class_name:"truecolor"};i?this.fg=p:this.bg=p}}}}}transform_to_html(e){let s=e.text;if(s.length===0||(s=this.escape_txt_for_html(s),!e.bold&&!e.italic&&!e.faint&&!e.underline&&e.fg===null&&e.bg===null))return s;let r=[],n=[],i=e.fg,a=e.bg;e.bold&&r.push(this._boldStyle),e.faint&&r.push(this._faintStyle),e.italic&&r.push(this._italicStyle),e.underline&&r.push(this._underlineStyle),this._use_classes?(i&&(i.class_name!=="truecolor"?n.push(`${i.class_name}-fg`):r.push(`color:rgb(${i.rgb.join(",")})`)),a&&(a.class_name!=="truecolor"?n.push(`${a.class_name}-bg`):r.push(`background-color:rgb(${a.rgb.join(",")})`))):(i&&r.push(`color:rgb(${i.rgb.join(",")})`),a&&r.push(`background-color:rgb(${a.rgb})`));let u="",c="";return n.length&&(u=` class="${n.join(" ")}"`),r.length&&(c=` style="${r.join(";")}"`),`${s}`}process_hyperlink(e){let s=e.url.split(":");return s.length<1||!this._url_allowlist[s[0]]?"":`${this.escape_txt_for_html(e.text)}`}}function y(t,...e){let s=t.raw[0],r=/^\s+|\s+\n|\s*#[\s\S]*?\n|\n/gm,n=s.replace(r,"");return new RegExp(n)}function Z(t,...e){let s=t.raw[0],r=/^\s+|\s+\n|\s*#[\s\S]*?\n|\n/gm,n=s.replace(r,"");return new RegExp(n,"g")}var w,E,j;const J=/\x1b\][^\x07\x1b\x9c]*(?:\x07|\x1b\\|\x9c)/g,W=/\x1b\[[?0-9;]*[a-ln-zA-Z]/g,X=/\x1b(?!\[[?0-9;]*m)[@-Z\\-_]?/g,Q=/[\x00-\x08\x0b\x0c\x0e-\x1a\x1c-\x1f\x7f-\x9f]/g;function Y(t){return t.replace(J,"").replace(W,"").replace(X,"").replace(Q,"")}function K({children:t,caption:e}){const[s,r]=d.useState(!1),n=d.useRef(null),i=d.useRef(null),a=d.useRef(!0),[u,c]=d.useState(!1),f=d.useId(),p=s?i:n;d.useEffect(()=>{const g=p.current;!g||!a.current||(g.scrollTop=g.scrollHeight)});const h=d.useCallback(g=>{const x=g.currentTarget;a.current=x.scrollHeight-x.scrollTop-x.clientHeight<8},[]);d.useEffect(()=>{if(!s){c(!1);return}if(window.matchMedia("(prefers-reduced-motion: reduce)").matches){c(!0);return}const g=requestAnimationFrame(()=>c(!0));return()=>cancelAnimationFrame(g)},[s]),d.useEffect(()=>{if(!s)return;const g=x=>{x.key==="Escape"&&(x.stopPropagation(),r(!1),a.current=!0)};return document.addEventListener("keydown",g,!0),()=>document.removeEventListener("keydown",g,!0)},[s]);const _=()=>{a.current=!0,r(!0)},m=()=>{a.current=!0,r(!1)};return s?l.jsxs(l.Fragment,{children:[l.jsx("div",{className:"fixed inset-0 z-[60] bg-fg/30","aria-hidden":"true",onClick:m}),l.jsxs("div",{role:"dialog","aria-modal":"true","aria-labelledby":f,className:["fixed inset-[5%] z-[61] flex flex-col","bg-surface border border-rule rounded-md","transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none",u?"opacity-100 scale-100":"opacity-0 scale-95"].join(" "),onClick:g=>g.stopPropagation(),children:[l.jsxs("div",{className:"px-5 pt-4 pb-3 border-b border-rule shrink-0 space-y-1",children:[l.jsx("h2",{id:f,className:"text-label uppercase tracking-wider text-fg-faint",children:"Chat Transcript"}),l.jsxs("div",{className:"flex items-baseline gap-3",children:[e&&l.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:e}),l.jsx("button",{type:"button",onClick:m,"aria-label":"Collapse transcript",className:"text-label uppercase tracking-wider text-fg-muted hover:text-fg transition-colors duration-150 ease-out focus-mark rounded-sm px-1 ml-auto",children:"collapse ×"})]})]}),l.jsx("div",{ref:i,onScroll:h,className:"flex-1 overflow-y-auto p-5",children:t})]})]}):l.jsxs("div",{children:[l.jsx("header",{className:"flex items-baseline justify-end mb-4 gap-3",children:l.jsxs("div",{className:"flex items-baseline gap-3 min-w-0",children:[e&&l.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum shrink-0",children:e}),l.jsx("button",{type:"button",onClick:_,"aria-label":"Expand transcript",className:"text-label uppercase tracking-wider text-fg-muted hover:text-fg transition-colors duration-150 ease-out focus-mark rounded-sm shrink-0",children:"expand ⤢"})]})}),l.jsx("div",{ref:n,onScroll:h,className:"h-96 overflow-y-auto p-4",children:t})]})}const k=512,ee=/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?(?!\d)/;function te(t){if(t.length===0)return null;const s=(t.length>k?t.slice(0,k):t).match(ee);return s?s[0]:null}function se({loading:t,error:e,result:s,caption:r}){if(t&&s===null)return l.jsx("p",{className:"text-fg-muted italic",children:"Fetching transcript."});if(e)return l.jsx("p",{className:"text-accent",role:"alert",children:e});if(!s)return null;if(s.turns.length===0)return l.jsx("p",{className:"text-fg-muted italic",children:"No turns in this session yet."});const n=Date.now();return l.jsx(K,{...r!==void 0?{caption:r}:{},children:l.jsxs("div",{className:"space-y-6",children:[r===void 0&&l.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint tnum",title:s.captured_at,children:M(s.captured_at)}),l.jsxs("p",{className:"text-label uppercase tracking-wider text-warn",children:["▲ ",F]}),l.jsx("ol",{className:"space-y-5",children:s.turns.map((i,a)=>l.jsx(ne,{turn:i,index:a,now:n},a))}),s.truncated&&l.jsxs("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:["Some turns truncated at the per-turn or total cap. Run"," ",l.jsx("code",{className:"text-fg-muted",children:"gc session peek"})," in a terminal for the full transcript."]})]})})}function ne({turn:t,index:e,now:s}){const r=d.useMemo(()=>re(t.text),[t.text]),n=d.useMemo(()=>te(t.text),[t.text]);return l.jsxs("li",{children:[l.jsxs("header",{className:"flex items-start justify-between gap-3 pb-2 border-b border-rule mb-2",children:[l.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:["#",(e+1).toString().padStart(2,"0")]}),l.jsxs("div",{className:"flex flex-col items-end leading-tight",title:n??void 0,children:[l.jsx("span",{className:"text-body text-fg tnum",children:U(n)}),l.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:v(n,s)}),l.jsx(ie,{role:t.role})]})]}),l.jsx("pre",{className:"text-body whitespace-pre-wrap leading-relaxed overflow-x-auto text-fg",children:r})]})}function re(t){const e=Y(t),s=new P;s.use_classes=!0;const r=s.ansi_to_html(e);if(typeof DOMParser>"u")return[e];const n=new DOMParser().parseFromString(`${r}`,"text/html");return Array.from(n.body.childNodes).map((i,a)=>R(i,String(a)))}function R(t,e){if(t.nodeType===3)return t.textContent??"";if(t.nodeType!==1)return null;const s=t,r=Array.from(s.childNodes).map((n,i)=>R(n,`${e}-${i}`));return s.tagName.toLowerCase()==="br"?l.jsx("br",{},e):s.tagName.toLowerCase()!=="span"?l.jsx("span",{children:r},e):l.jsx("span",{className:s.getAttribute("class")??void 0,children:r},e)}function ie({role:t}){const e=ae(t);return l.jsx("span",{className:`text-label uppercase tracking-wider font-medium ${e}`,children:t.replace(/_/g," ")})}function ae(t){switch(t){case"assistant":return"text-accent";case"user":return"text-fg";case"system":return"text-warn";case"tool_use":case"tool_result":return"text-fg-muted";default:return"text-fg-faint"}}function de({sessionId:t,stream:e,showBadge:s=!0,showCaption:r=!1}){const n=q(t,e),i=n.status==="ready"?n.result:null,a=n.status==="loading",u=n.status==="failed"?n.error:null,c=le(n.stream),f=[];return r&&i&&(f.push(`${i.turns.length} turn(s)`),f.push(D(i.total_chars,"chars")),f.push(`captured ${v(i.captured_at,Date.now())}`)),l.jsxs("div",{className:"space-y-4",children:[s&&l.jsx("div",{className:"flex justify-end",children:l.jsx(B,{tone:c.tone,label:c.label,title:`Session stream: ${n.stream.status}`,className:"text-label uppercase tracking-wider"})}),l.jsx(se,{loading:a,error:u,result:i,...f.length>0?{caption:f.join(" · ")}:{}})]})}function le(t){switch(typeof t=="string"?t:t.status){case"open":return{tone:"ok",label:"live"};case"connecting":return{tone:"warn",label:"connecting"};case"closed":return{tone:"stuck",label:"offline"};case"degraded":return{tone:"warn",label:"degraded"};case"idle":return{tone:"neutral",label:"snapshot"}}}function he(t){return t===null?!1:t.running===!0||t.state==="active"||t.state==="running"}function pe(t){return t===null||!t.session?!1:t.running===!0||t.state==="active"||t.state==="running"}export{de as L,se as S,he as a,pe as i,q as u}; + `]))));let a=this._buffer.match(this._osc_regex);if(a===null)return e.kind=o.ESC,e.text=this._buffer.slice(0,1),this._buffer=this._buffer.slice(1),e;e.kind=o.OSCURL,e.url=a[1],e.text=a[2];var i=a[0].length;return this._buffer=this._buffer.slice(i),e}else if(n=="(")return e.kind=o.Unknown,this._buffer=this._buffer.slice(3),e}}ansi_to_html(e){this.append_buffer(e);for(var s=[];;){var r=this.get_next_packet();if(r.kind==o.EOS||r.kind==o.Incomplete)break;r.kind==o.ESC||r.kind==o.Unknown||(r.kind==o.Text?s.push(this.transform_to_html(this.with_state(r))):r.kind==o.SGR?this.process_ansi(r):r.kind==o.OSCURL&&s.push(this.process_hyperlink(r)))}return s.join("")}with_state(e){return{bold:this.bold,faint:this.faint,italic:this.italic,underline:this.underline,fg:this.fg,bg:this.bg,text:e.text}}process_ansi(e){let s=e.text.split(";");for(;s.length>0;){let r=s.shift(),n=parseInt(r,10);if(isNaN(n)||n===0)this.fg=null,this.bg=null,this.bold=!1,this.faint=!1,this.italic=!1,this.underline=!1;else if(n===1)this.bold=!0;else if(n===2)this.faint=!0;else if(n===3)this.italic=!0;else if(n===4)this.underline=!0;else if(n===21)this.bold=!1;else if(n===22)this.faint=!1,this.bold=!1;else if(n===23)this.italic=!1;else if(n===24)this.underline=!1;else if(n===39)this.fg=null;else if(n===49)this.bg=null;else if(n>=30&&n<38)this.fg=this.ansi_colors[0][n-30];else if(n>=40&&n<48)this.bg=this.ansi_colors[0][n-40];else if(n>=90&&n<98)this.fg=this.ansi_colors[1][n-90];else if(n>=100&&n<108)this.bg=this.ansi_colors[1][n-100];else if((n===38||n===48)&&s.length>0){let i=n===38,a=s.shift();if(a==="5"&&s.length>0){let u=parseInt(s.shift(),10);u>=0&&u<=255&&(i?this.fg=this.palette_256[u]:this.bg=this.palette_256[u])}if(a==="2"&&s.length>2){let u=parseInt(s.shift(),10),c=parseInt(s.shift(),10),f=parseInt(s.shift(),10);if(u>=0&&u<=255&&c>=0&&c<=255&&f>=0&&f<=255){let p={rgb:[u,c,f],class_name:"truecolor"};i?this.fg=p:this.bg=p}}}}}transform_to_html(e){let s=e.text;if(s.length===0||(s=this.escape_txt_for_html(s),!e.bold&&!e.italic&&!e.faint&&!e.underline&&e.fg===null&&e.bg===null))return s;let r=[],n=[],i=e.fg,a=e.bg;e.bold&&r.push(this._boldStyle),e.faint&&r.push(this._faintStyle),e.italic&&r.push(this._italicStyle),e.underline&&r.push(this._underlineStyle),this._use_classes?(i&&(i.class_name!=="truecolor"?n.push(`${i.class_name}-fg`):r.push(`color:rgb(${i.rgb.join(",")})`)),a&&(a.class_name!=="truecolor"?n.push(`${a.class_name}-bg`):r.push(`background-color:rgb(${a.rgb.join(",")})`))):(i&&r.push(`color:rgb(${i.rgb.join(",")})`),a&&r.push(`background-color:rgb(${a.rgb})`));let u="",c="";return n.length&&(u=` class="${n.join(" ")}"`),r.length&&(c=` style="${r.join(";")}"`),`${s}`}process_hyperlink(e){let s=e.url.split(":");return s.length<1||!this._url_allowlist[s[0]]?"":`${this.escape_txt_for_html(e.text)}`}}function y(t,...e){let s=t.raw[0],r=/^\s+|\s+\n|\s*#[\s\S]*?\n|\n/gm,n=s.replace(r,"");return new RegExp(n)}function Z(t,...e){let s=t.raw[0],r=/^\s+|\s+\n|\s*#[\s\S]*?\n|\n/gm,n=s.replace(r,"");return new RegExp(n,"g")}var w,E,j;const J=/\x1b\][^\x07\x1b\x9c]*(?:\x07|\x1b\\|\x9c)/g,W=/\x1b\[[?0-9;]*[a-ln-zA-Z]/g,X=/\x1b(?!\[[?0-9;]*m)[@-Z\\-_]?/g,Q=/[\x00-\x08\x0b\x0c\x0e-\x1a\x1c-\x1f\x7f-\x9f]/g;function Y(t){return t.replace(J,"").replace(W,"").replace(X,"").replace(Q,"")}function K({children:t,caption:e}){const[s,r]=d.useState(!1),n=d.useRef(null),i=d.useRef(null),a=d.useRef(!0),[u,c]=d.useState(!1),f=d.useId(),p=s?i:n;d.useEffect(()=>{const g=p.current;!g||!a.current||(g.scrollTop=g.scrollHeight)});const h=d.useCallback(g=>{const x=g.currentTarget;a.current=x.scrollHeight-x.scrollTop-x.clientHeight<8},[]);d.useEffect(()=>{if(!s){c(!1);return}if(window.matchMedia("(prefers-reduced-motion: reduce)").matches){c(!0);return}const g=requestAnimationFrame(()=>c(!0));return()=>cancelAnimationFrame(g)},[s]),d.useEffect(()=>{if(!s)return;const g=x=>{x.key==="Escape"&&(x.stopPropagation(),r(!1),a.current=!0)};return document.addEventListener("keydown",g,!0),()=>document.removeEventListener("keydown",g,!0)},[s]);const _=()=>{a.current=!0,r(!0)},m=()=>{a.current=!0,r(!1)};return s?l.jsxs(l.Fragment,{children:[l.jsx("div",{className:"fixed inset-0 z-[60] bg-fg/30","aria-hidden":"true",onClick:m}),l.jsxs("div",{role:"dialog","aria-modal":"true","aria-labelledby":f,className:["fixed inset-[5%] z-[61] flex flex-col","bg-surface border border-rule rounded-md","transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none",u?"opacity-100 scale-100":"opacity-0 scale-95"].join(" "),onClick:g=>g.stopPropagation(),children:[l.jsxs("div",{className:"px-5 pt-4 pb-3 border-b border-rule shrink-0 space-y-1",children:[l.jsx("h2",{id:f,className:"text-label uppercase tracking-wider text-fg-faint",children:"Chat Transcript"}),l.jsxs("div",{className:"flex items-baseline gap-3",children:[e&&l.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:e}),l.jsx("button",{type:"button",onClick:m,"aria-label":"Collapse transcript",className:"text-label uppercase tracking-wider text-fg-muted hover:text-fg transition-colors duration-150 ease-out focus-mark rounded-sm px-1 ml-auto",children:"collapse ×"})]})]}),l.jsx("div",{ref:i,onScroll:h,className:"flex-1 overflow-y-auto p-5",children:t})]})]}):l.jsxs("div",{children:[l.jsx("header",{className:"flex items-baseline justify-end mb-4 gap-3",children:l.jsxs("div",{className:"flex items-baseline gap-3 min-w-0",children:[e&&l.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum shrink-0",children:e}),l.jsx("button",{type:"button",onClick:_,"aria-label":"Expand transcript",className:"text-label uppercase tracking-wider text-fg-muted hover:text-fg transition-colors duration-150 ease-out focus-mark rounded-sm shrink-0",children:"expand ⤢"})]})}),l.jsx("div",{ref:n,onScroll:h,className:"h-96 overflow-y-auto p-4",children:t})]})}const k=512,ee=/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?(?!\d)/;function te(t){if(t.length===0)return null;const s=(t.length>k?t.slice(0,k):t).match(ee);return s?s[0]:null}function se({loading:t,error:e,result:s,caption:r}){if(t&&s===null)return l.jsx("p",{className:"text-fg-muted italic",children:"Fetching transcript."});if(e)return l.jsx("p",{className:"text-accent",role:"alert",children:e});if(!s)return null;if(s.turns.length===0)return l.jsx("p",{className:"text-fg-muted italic",children:"No turns in this session yet."});const n=Date.now();return l.jsx(K,{...r!==void 0?{caption:r}:{},children:l.jsxs("div",{className:"space-y-6",children:[r===void 0&&l.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint tnum",title:s.captured_at,children:M(s.captured_at)}),l.jsxs("p",{className:"text-label uppercase tracking-wider text-warn",children:["▲ ",D]}),l.jsx("ol",{className:"space-y-5",children:s.turns.map((i,a)=>l.jsx(ne,{turn:i,index:a,now:n},a))}),s.truncated&&l.jsxs("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:["Some turns truncated at the per-turn or total cap. Run"," ",l.jsx("code",{className:"text-fg-muted",children:"gc session peek"})," in a terminal for the full transcript."]})]})})}function ne({turn:t,index:e,now:s}){const r=d.useMemo(()=>re(t.text),[t.text]),n=d.useMemo(()=>te(t.text),[t.text]);return l.jsxs("li",{children:[l.jsxs("header",{className:"flex items-start justify-between gap-3 pb-2 border-b border-rule mb-2",children:[l.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:["#",(e+1).toString().padStart(2,"0")]}),l.jsxs("div",{className:"flex flex-col items-end leading-tight",title:n??void 0,children:[l.jsx("span",{className:"text-body text-fg tnum",children:U(n)}),l.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:v(n,s)}),l.jsx(ie,{role:t.role})]})]}),l.jsx("pre",{className:"text-body whitespace-pre-wrap leading-relaxed overflow-x-auto text-fg",children:r})]})}function re(t){const e=Y(t),s=new P;s.use_classes=!0;const r=s.ansi_to_html(e);if(typeof DOMParser>"u")return[e];const n=new DOMParser().parseFromString(`${r}`,"text/html");return Array.from(n.body.childNodes).map((i,a)=>R(i,String(a)))}function R(t,e){if(t.nodeType===3)return t.textContent??"";if(t.nodeType!==1)return null;const s=t,r=Array.from(s.childNodes).map((n,i)=>R(n,`${e}-${i}`));return s.tagName.toLowerCase()==="br"?l.jsx("br",{},e):s.tagName.toLowerCase()!=="span"?l.jsx("span",{children:r},e):l.jsx("span",{className:s.getAttribute("class")??void 0,children:r},e)}function ie({role:t}){const e=ae(t);return l.jsx("span",{className:`text-label uppercase tracking-wider font-medium ${e}`,children:t.replace(/_/g," ")})}function ae(t){switch(t){case"assistant":return"text-accent";case"user":return"text-fg";case"system":return"text-warn";case"tool_use":case"tool_result":return"text-fg-muted";default:return"text-fg-faint"}}function de({sessionId:t,stream:e,showBadge:s=!0,showCaption:r=!1}){const n=q(t,e),i=n.status==="ready"?n.result:null,a=n.status==="loading",u=n.status==="failed"?n.error:null,c=le(n.stream),f=[];return r&&i&&(f.push(`${i.turns.length} turn(s)`),f.push(F(i.total_chars,"chars")),f.push(`captured ${v(i.captured_at,Date.now())}`)),l.jsxs("div",{className:"space-y-4",children:[s&&l.jsx("div",{className:"flex justify-end",children:l.jsx(B,{tone:c.tone,label:c.label,title:`Session stream: ${n.stream.status}`,className:"text-label uppercase tracking-wider"})}),l.jsx(se,{loading:a,error:u,result:i,...f.length>0?{caption:f.join(" · ")}:{}})]})}function le(t){switch(typeof t=="string"?t:t.status){case"open":return{tone:"ok",label:"live"};case"connecting":return{tone:"warn",label:"connecting"};case"closed":return{tone:"stuck",label:"offline"};case"degraded":return{tone:"warn",label:"degraded"};case"idle":return{tone:"neutral",label:"snapshot"}}}function he(t){return t===null?!1:t.running===!0||t.state==="active"||t.state==="running"}function pe(t){return t===null||!t.session?!1:t.running===!0||t.state==="active"||t.state==="running"}export{de as L,se as S,he as a,pe as i,q as u}; diff --git a/internal/api/dashboardspa/dist/assets/Mail-767k9Nkh.js b/internal/api/dashboardspa/dist/assets/Mail-CA7kddiW.js similarity index 93% rename from internal/api/dashboardspa/dist/assets/Mail-767k9Nkh.js rename to internal/api/dashboardspa/dist/assets/Mail-CA7kddiW.js index 9b9ba781eb..d1f4564f4c 100644 --- a/internal/api/dashboardspa/dist/assets/Mail-767k9Nkh.js +++ b/internal/api/dashboardspa/dist/assets/Mail-CA7kddiW.js @@ -1,3 +1,3 @@ -import{j as e,r,w as re,M as L,N as qe,I as F,J as B,v as Ce,g as Me,z as ae,R as ne,S as se,B as M,i as _,a as Ue,K as Ye,O as Ae,P as Le,u as Ke,b as Ve,A as Ge,Q as be,T as Qe,U as Je,V as Re,W as Ie}from"./index-C20tCZFz.js";import{a as Xe,L as Ze,m as et}from"./projectOf-CwPPScnJ.js";import{a as tt,r as je}from"./routeHighlight-B30gQO2o.js";import{u as at,F as st}from"./useListFilters-C0Eq1DLc.js";import{T as rt}from"./Table-DojZJIvD.js";import{M as _e,P as nt}from"./constants-DBKWGg29.js";import{P as lt}from"./PageHeader-D_D-jYn1.js";import{F as P}from"./Field-Dsl4x4KL.js";import{f as it}from"./time-D9v0saHV.js";function q(t){const a=t.trim();if(a.length===0||!a.includes("/")&&!a.includes("\\"))return a;const i=a.split(/[\\/]/).filter(m=>m.length>0),c=i[i.length-1];if(c===void 0)return a;const n=i[i.length-2];if(n===void 0)return c;const l=c.startsWith(`${n}-`)?c.slice(n.length+1):c;return`${Xe(n)} · ${l}`}function ot({collapsed:t,onToggle:a,children:i,className:c="w-full flex items-baseline justify-between gap-4 focus-mark",glyphClassName:n}){return e.jsx("button",{type:"button",onClick:a,className:c,"aria-expanded":!t,children:i({glyph:e.jsx(ct,{collapsed:t,className:n??""})})})}function ct({collapsed:t,className:a=""}){return e.jsx("span",{"aria-hidden":!0,className:`inline-block text-fg-faint transition-transform duration-150 ease-out-quart ${a}`,style:{transform:t?"rotate(-90deg)":"rotate(0deg)"},children:"▾"})}function dt({project:t,count:a,collapsed:i,onToggle:c,collapsible:n=!0}){return n?e.jsx(ot,{collapsed:i,onToggle:c,className:"group flex items-baseline gap-2 w-full text-left focus-mark rounded-sm py-1",glyphClassName:"group-hover:text-fg-muted tnum w-3",children:({glyph:l})=>e.jsxs(e.Fragment,{children:[l,e.jsx("span",{className:"text-title font-medium text-fg group-hover:text-fg",children:t}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:a})]})}):e.jsxs("div",{role:"heading","aria-level":2,className:"flex items-baseline gap-2 py-1 text-label uppercase tracking-wider text-fg-faint",children:[e.jsx("span",{"aria-hidden":!0,children:"·"}),e.jsx("span",{children:t}),e.jsx("span",{"aria-hidden":!0,children:"·"})]})}function ut({groups:t,columns:a,rowKey:i,onToggleProject:c,onRowClick:n,rowProps:l,emptyMessage:m,perProjectEmpty:b,initialSort:h}){return t.length===0?e.jsx("p",{className:"py-10 text-center text-fg-muted italic",children:m}):e.jsx("div",{className:"space-y-8",children:t.map(d=>e.jsxs("section",{children:[e.jsx(dt,{project:d.project,count:d.totalInProject,collapsed:d.collapsed,onToggle:()=>c(d.projectKey),collapsible:d.collapsible}),!d.collapsed&&e.jsx(rt,{columns:a,rows:d.rows,rowKey:i,empty:b??"No items.",...n!==void 0?{onRowClick:n}:{},...l!==void 0?{rowProps:l}:{},...h!==void 0?{initialSort:h}:{}})]},d.projectKey))})}const ye="border-rule pb-6 border-b sm:shrink-0 sm:pr-6 sm:pb-0 sm:border-b-0 sm:border-r";function mt({buckets:t,loading:a,sessionsUnavailable:i,value:c,onChange:n,onReset:l,isOperator:m}){const[b,h]=r.useState(!1),[d,S]=r.useState(""),{operatorAlias:y,operatorWireAlias:u}=re(),A=L(c,y),x=r.useMemo(()=>{const f=d.trim().toLowerCase();return t.map(g=>({tier:g.tier,aliases:g.aliases.filter(v=>v.toLowerCase()===u?!1:f.length===0?!0:L(v,y).toLowerCase().includes(f)||v.toLowerCase().includes(f))})).filter(g=>g.aliases.length>0)},[t,d,y,u]),R=f=>{n(f),h(!1),S("")};return b?e.jsxs("aside",{className:`${ye} sm:w-64`,children:[e.jsx("button",{type:"button",onClick:()=>h(!1),"aria-expanded":!0,className:"text-label uppercase tracking-wider text-fg-muted hover:text-fg focus-mark rounded-sm",children:"▾ Agents"}),e.jsxs("div",{className:"mt-2 text-label uppercase tracking-wider text-fg-faint",children:[m?"Reading as":e.jsx("span",{className:"text-accent",children:"▲ Reading as"})," ",e.jsx("span",{className:`not-italic ${m?"text-fg-muted":"text-accent"}`,children:A})]}),e.jsx("div",{className:"mt-3 border-b border-rule pb-1",children:e.jsx("input",{type:"search",value:d,onChange:f=>S(f.target.value),placeholder:"Find an agent","aria-label":"Find an agent",autoFocus:!0,className:"w-full bg-transparent border-0 text-body text-fg placeholder:text-fg-faint focus:outline-none focus:ring-0 px-0 py-0.5"})}),e.jsxs("div",{className:"mt-3 max-h-[28rem] overflow-y-auto -mr-2 pr-2 space-y-4",children:[x.length===0?e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:a?"Loading aliases":"No agents match."}):x.map(f=>e.jsxs("div",{children:[e.jsx("div",{className:"text-label uppercase tracking-wider text-fg-faint mb-1",children:qe(f.tier)}),e.jsx("ul",{className:"space-y-0.5",children:f.aliases.map(g=>{const v=g.toLowerCase()===c.toLowerCase();return e.jsx("li",{children:e.jsx("button",{type:"button",onClick:()=>R(g),"aria-current":v,className:`block w-full text-left truncate text-body transition-colors duration-150 ease-out-quart focus-mark rounded-sm py-0.5 ${v?"text-fg font-semibold":"text-fg-muted hover:text-fg"}`,title:L(g,y),children:L(g,y)})},g)})})]},f.tier)),a&&x.length>0&&e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:"Loading more agents"}),!a&&i&&x.length>0&&(ft(x)?e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:"Agent list and mail history both unavailable."}):e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:"Agent list unavailable; showing mail-derived aliases only."}))]}),!m&&e.jsxs("div",{className:"mt-4 pt-3 border-t border-rule space-y-2",children:[e.jsx("button",{type:"button",onClick:l,className:"block text-label uppercase tracking-wider text-fg-muted hover:text-fg focus-mark underline decoration-dotted underline-offset-2 rounded-sm",children:"Back to operator"}),e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:"Read-only. Sends always go from the operator."})]})]}):e.jsxs("aside",{className:`${ye} sm:w-44`,children:[e.jsx("button",{type:"button",onClick:()=>h(!0),"aria-expanded":!1,className:"text-label uppercase tracking-wider text-fg-muted hover:text-fg focus-mark rounded-sm",children:"▸ Agents"}),e.jsx("div",{className:"mt-4 text-label uppercase tracking-wider text-fg-faint",children:m?"Reading as":e.jsx("span",{className:"text-accent",children:"▲ Reading as"})}),e.jsx("div",{className:`mt-1 text-body truncate ${m?"text-fg":"text-accent font-medium"}`,title:A,children:A}),!m&&e.jsxs(e.Fragment,{children:[e.jsx("button",{type:"button",onClick:l,className:"mt-3 block text-label uppercase tracking-wider text-fg-muted hover:text-fg focus-mark underline decoration-dotted underline-offset-2 rounded-sm",children:"Back to operator"}),e.jsx("p",{className:"mt-2 text-label uppercase tracking-wider text-fg-faint italic",children:"Read-only. Sends go from the operator."})]})]})}function ft(t){let a=0;for(const i of t)if(a+=i.aliases.length,a>1)return!1;return a<=1}async function pt(t,a){await F().sendMail(B("send supervisor mail"),{...t,from:a})}async function ve(t){await F().markMailRead(B("mark supervisor mail read"),t.id,U(t))}async function we(t){await F().markMailUnread(B("mark supervisor mail unread"),t.id,U(t))}async function xt(t){await F().archiveMail(B("archive supervisor mail"),t.id,U(t))}async function ht(t,a,i){await F().replyMail(B("reply supervisor mail"),t.id,{...a,from:i},U(t))}function U(t){return t.rig===void 0||t.rig.length===0?void 0:{rig:t.rig}}function gt({open:t,onClose:a,onSent:i}){const{viewingAs:c}=Ce(),n=Me(),{operatorAlias:l,operatorWireAlias:m}=re(),[b,h]=r.useState(""),[d,S]=r.useState(""),[y,u]=r.useState(""),[A,x]=r.useState(!1),[R,f]=r.useState(null);r.useEffect(()=>{t||(h(""),S(""),u(""),f(null))},[t]);const g=r.useCallback(async()=>{if(!n){x(!0),f(null);try{await pt({to:b,subject:d,body:y},m),i()}catch(N){f(ae(N,"send failed"))}finally{x(!1)}}},[y,i,n,d,b,m]),v=!n&&c.isOperator&&b.length>0&&d.length>0&&y.length>0&&!A;return e.jsx(_e,{open:t,onClose:a,title:"New message",caption:"Sends from the operator. Reading-as has no effect on the sender.",widthClass:"max-w-2xl",footer:e.jsxs(e.Fragment,{children:[e.jsx(M,{tone:"quiet",size:"sm",onClick:a,children:"Cancel"}),e.jsx(M,{tone:"accent",size:"sm",disabled:!v,title:n?_:void 0,onClick:()=>{g()},children:A?"Sending":"Send"})]}),children:e.jsxs("div",{className:"space-y-4",children:[e.jsx(P,{label:"From",variant:"form",children:e.jsx("input",{type:"text",value:c.isOperator?L(l,l):`${L(l,l)} (reading-as does not change sender)`,disabled:!0,className:"w-full bg-transparent border-0 border-b border-rule pb-1 text-body text-fg-muted italic"})}),e.jsx(P,{label:"To (alias)",variant:"form",children:e.jsx("input",{type:"text",autoFocus:!0,value:b,onChange:N=>h(N.target.value),placeholder:"mayor, mechanic, scix-worker, …",className:"w-full bg-transparent border-0 border-b border-rule pb-1 text-body text-fg placeholder:text-fg-faint focus:border-accent focus:outline-none transition-colors"})}),e.jsx(P,{label:"Subject",variant:"form",children:e.jsx("input",{type:"text",value:d,onChange:N=>S(N.target.value),maxLength:200,className:"w-full bg-transparent border-0 border-b border-rule pb-1 text-body text-fg focus:border-accent focus:outline-none transition-colors"})}),e.jsx(P,{label:"Body",variant:"form",children:e.jsx("textarea",{value:y,onChange:N=>u(N.target.value),rows:10,maxLength:16*1024,className:"w-full bg-surface-tint border border-rule rounded-sm px-3 py-2 text-body text-fg focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40 resize-y"})}),n&&e.jsx(ne,{}),!c.isOperator&&e.jsx(se,{tone:"warn",label:`Reading as ${L(c.alias,l)}. Sends from this modal are structurally locked to the operator regardless.`}),R&&e.jsx(se,{tone:"stuck",label:R})]})})}function Ne({message:t,attentionSeverity:a=null}){return e.jsxs("article",{...bt(a),className:"space-y-3 pb-4 border-b border-rule last:border-0",children:[e.jsxs("header",{className:"flex items-baseline justify-between gap-3",children:[e.jsxs("div",{className:"text-label uppercase tracking-wider text-fg-muted truncate",children:[e.jsx("span",{className:"text-fg font-medium",children:q(t.from)}),e.jsx("span",{className:"mx-1.5 text-fg-faint",children:"→"}),e.jsx("span",{children:q(t.to)})]}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:jt(t.created_at)})]}),e.jsx("p",{className:"text-title font-semibold text-fg",children:t.subject}),e.jsx(se,{tone:"warn",label:nt}),e.jsx("pre",{className:"text-body whitespace-pre-wrap leading-relaxed text-fg overflow-x-auto",children:t.body})]})}function bt(t){return t===null?{}:{"data-attention-severity":t}}function jt(t){const a=Date.parse(t);return Number.isFinite(a)?new Date(a).toLocaleString(void 0,{month:"short",day:"numeric",hour:"numeric",minute:"2-digit"}):"·"}const ke=[{id:"unread",label:"unread",match:t=>!t.read},{id:"read",label:"read",match:t=>t.read}],yt={id:"needs-you",label:"needs you",match:t=>!t.read&&!Je(t.from)},vt=t=>[t.from,t.to,t.subject,t.rig,t.body.split(` -`)[0]],wt=1e3;function Ft(){const t=Ue(),a=Me(),i=re(),[c]=Ye(),n=At(c.get("message")),{viewingAs:l,setAlias:m,resetToOperator:b,aliasBuckets:h,aliasesLoading:d,sessionsUnavailable:S,loadAliases:y}=Ce(),[u,A]=r.useState(()=>n===null?"inbox":"all"),[x,R]=r.useState(()=>n===null?Ae:wt),[f,g]=r.useState(Le);r.useEffect(()=>{y()},[y]);const v=Ke(),{data:N,loading:le,error:Y,refresh:O}=Ve(`mail:${u}:${l.alias}:${i.operatorWireAlias}:${x}:${f}`,()=>Ge(u,l.alias,i,x,f,v)),j=r.useMemo(()=>N?.items??[],[N]),[ie,I]=r.useState(null);r.useEffect(()=>{Y&&I(Y)},[Y]);const[w,T]=r.useState(null),[K,W]=r.useState([]),[$e,oe]=r.useState(!1),V=r.useRef(null),[H,G]=r.useState(""),[E,ce]=r.useState(null),[Te,Q]=r.useState(!1),[$,z]=r.useState(()=>new Set),[Ee,de]=r.useState(null),J=r.useCallback(async s=>{if(T(s),W([]),G(""),I(null),!!s.thread_id){oe(!0);try{const o=await be(s.thread_id,l.alias,i,x);W(o.items)}catch(o){I(o instanceof Error?o.message:"thread failed")}finally{oe(!1)}}},[x,l.alias,i]);r.useEffect(()=>{if(n===null){V.current=null;return}if(V.current===n)return;const s=j.find(o=>o.id===n);s!==void 0&&(V.current=n,J(s))},[j,J,n]);const X=r.useCallback(async s=>{const o=w;if(o!==null&&!a){ce(s),I(null);try{if(s==="read")await ve(o),T({...o,read:!0});else if(s==="unread")await we(o),T({...o,read:!1});else if(s==="archive")await xt(o),T(null),W([]);else{const p=H.trim();if(p.length===0)return;if(await ht(o,{body:p},i.operatorWireAlias),G(""),o.thread_id){const De=await be(o.thread_id,l.alias,i,x);W(De.items)}}await O()}catch(p){I(ae(p,`${s} failed`))}finally{ce(null)}}},[x,a,O,H,w,l.alias,i]),ue=r.useMemo(()=>[{key:"from",label:"From",sortable:!0,sortValue:s=>q(s.from),render:s=>e.jsx("span",{className:"text-fg-muted",children:q(s.from)}),className:"w-48"},{key:"subject",label:"Subject",sortable:!0,sortValue:s=>s.subject,render:s=>e.jsxs("div",{className:"min-w-0",children:[e.jsx("p",{className:`truncate ${s.read?"text-fg-muted":"text-fg font-medium"}`,children:s.subject}),e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint mt-1 truncate",children:s.body.split(` -`)[0]??""})]})},{key:"created_at",label:"When",sortable:!0,sortValue:s=>s.created_at,render:s=>e.jsx("span",{className:"tnum text-fg-muted",children:it(s.created_at,v)}),className:"w-24",align:"right"}],[v]),D=r.useMemo(()=>L(l.alias,i.operatorAlias),[l.alias,i.operatorAlias]),Z=r.useMemo(()=>u==="inbox"&&l.isOperator?Qe(j).length:0,[u,j,l.isOperator]),Pe=r.useMemo(()=>{const s=u==="all"?"all mail":u==="inbox"?"inbox":"sent";if(j.length===0)return`${Oe(s)} empty for ${D}.`;const o=u==="sent"?0:j.filter(p=>!p.read).length;return u==="inbox"&&l.isOperator?o===0?`${j.length} in inbox, all read.`:Z>0?`${j.length} in inbox, ${Z} need you of ${o} unread.`:`${j.length} in inbox, ${o} unread, none need you.`:o>0?`${j.length} in ${s}, ${o} unread.`:`${j.length} in ${s}.`},[u,j,D,Z,l.isOperator]),me=r.useMemo(()=>l.isOperator?[yt,...ke]:ke,[l.isOperator]),k=at({viewKey:`mail:${u}`,rows:j,projectOf:et,searchOf:vt,chips:me}),fe=u!=="sent",C=r.useMemo(()=>k.groups.flatMap(s=>s.rows),[k.groups]),pe=r.useMemo(()=>C.reduce((s,o)=>$.has(o.id)?s+1:s,0),[C,$]),ee=C.length>0&&pe===C.length;r.useEffect(()=>{z(new Set)},[u,l.alias]);const xe=r.useCallback(s=>{z(o=>{const p=new Set(o);return p.has(s)?p.delete(s):p.add(s),p})},[]),Fe=r.useCallback(()=>{z(ee?new Set:new Set(C.map(s=>s.id)))},[ee,C]),he=r.useCallback(async s=>{if(a)return;const o=C.filter(p=>$.has(p.id)&&p.read!==s);if(o.length!==0){de(s?"read":"unread"),I(null);try{await Promise.all(o.map(p=>s?ve(p):we(p))),z(new Set)}catch(p){I(ae(p,`bulk mark ${s?"read":"unread"} failed`))}finally{de(null),await O()}}},[a,C,$,O]),Be=r.useMemo(()=>({key:"__select",label:"",className:"w-8",render:s=>e.jsx("input",{type:"checkbox",className:"h-3.5 w-3.5 translate-y-[2px] cursor-pointer accent-fg focus-mark",checked:$.has(s.id),onChange:()=>xe(s.id),onClick:o=>o.stopPropagation(),"aria-label":`select mail: ${s.subject}`})}),[$,xe]),We=fe?[Be,...ue]:ue,He=r.useMemo(()=>s=>tt(je(t,"mail",s.id)),[t]),ge=r.useCallback(s=>je(t,"mail",s.id),[t]),te=u==="sent"?[]:me,ze=a||w===null||H.trim().length===0||E!==null||!l.isOperator;return e.jsxs("section",{children:[e.jsx(lt,{title:"Mail",synopsis:Pe,meta:e.jsxs(e.Fragment,{children:[ie&&e.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:ie}),a&&e.jsx(ne,{}),e.jsx(M,{size:"sm",onClick:()=>Q(!0),disabled:a||!l.isOperator,title:a?_:l.isOperator?"Compose a new message (sends as the operator)":"Switch back to the operator to compose",children:"Compose"}),e.jsx(M,{size:"sm",onClick:()=>{O()},disabled:le,children:le?"Refreshing":"Refresh"})]})}),e.jsxs("div",{className:"flex flex-col gap-8 sm:flex-row sm:items-start",children:[e.jsx(mt,{buckets:h,loading:d,sessionsUnavailable:S,value:l.alias,onChange:m,onReset:b,isOperator:l.isOperator}),e.jsxs("div",{className:"flex-1 min-w-0",children:[e.jsx("div",{className:"mb-6",children:e.jsx(Nt,{box:u,onChange:A})}),e.jsxs("div",{className:"mb-6 space-y-3",children:[e.jsx(Ze,{value:k.search,onChange:k.setSearch,placeholder:"Search mail by sender, subject, rig",matchCount:k.totalMatches,totalCount:j.length,ariaLabel:"Search mail"}),te.length>0&&e.jsxs("div",{className:"flex items-baseline justify-between gap-4 flex-wrap",children:[e.jsx(st,{chips:te,activeIds:k.activeChipIds,onToggle:k.toggleChip,legend:"Read state"}),e.jsx(Se,{limit:x,onLimitChange:R,onWindowChange:g,window:f})]}),te.length===0&&e.jsx("div",{className:"flex justify-end",children:e.jsx(Se,{limit:x,onLimitChange:R,onWindowChange:g,window:f})})]}),fe&&C.length>0&&e.jsx("div",{className:"mb-6",children:e.jsx(kt,{selectedCount:pe,allSelected:ee,onToggleAll:Fe,onMarkRead:()=>{he(!0)},onMarkUnread:()=>{he(!1)},bulkInFlight:Ee,readOnly:a})}),e.jsx(ut,{groups:k.groups,columns:We,rowKey:s=>s.id,onToggleProject:k.toggleProject,onRowClick:s=>{J(s)},rowProps:He,emptyMessage:k.search.length>0||k.activeChipIds.size>0?"No messages match the current search or filter.":`${u==="inbox"?"Inbox":"Sent"} empty for ${D}.`,perProjectEmpty:"No messages in this project.",initialSort:{key:"created_at",dir:"desc"}})]})]}),e.jsx(_e,{open:w!==null,onClose:()=>T(null),title:w?.subject??"Thread",caption:`Reading as ${D}, ${K.length} message(s)`,widthClass:"max-w-3xl",footer:w===null?null:e.jsxs(e.Fragment,{children:[e.jsx(M,{tone:"quiet",size:"sm",title:a?_:void 0,disabled:a||E!==null,onClick:()=>{X(w.read?"unread":"read")},children:w.read?"Mark unread":"Mark read"}),e.jsx(M,{tone:"quiet",size:"sm",title:a?_:void 0,disabled:a||E!==null,onClick:()=>{X("archive")},children:E==="archive"?"Archiving":"Archive"}),e.jsx(M,{tone:"accent",size:"sm",title:a?_:void 0,disabled:ze,onClick:()=>{X("reply")},children:E==="reply"?"Replying":"Reply"})]}),children:e.jsxs("div",{className:"space-y-6",children:[$e?e.jsx("p",{className:"text-fg-muted italic",children:"Loading thread."}):K.length===0&&w?e.jsx(Ne,{message:w,attentionSeverity:ge(w)}):e.jsx("ol",{className:"space-y-6",children:K.map(s=>e.jsx("li",{children:e.jsx(Ne,{message:s,attentionSeverity:ge(s)})},s.id))}),w!==null&&e.jsx(P,{label:"Reply",variant:"form",children:e.jsx("textarea",{value:H,onChange:s=>G(s.target.value),rows:5,maxLength:16*1024,title:a?_:void 0,disabled:a||!l.isOperator,className:"w-full bg-surface-tint border border-rule rounded-sm px-3 py-2 text-body text-fg focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40 resize-y disabled:opacity-50"})})]})}),e.jsx(gt,{open:Te,onClose:()=>Q(!1),onSent:()=>{Q(!1),u==="sent"&&O()}})]})}function Nt({box:t,onChange:a}){return e.jsx("div",{className:"flex items-baseline gap-6",children:["inbox","sent","all"].map(i=>e.jsx("button",{type:"button",onClick:()=>a(i),className:`text-title transition-colors duration-150 ease-out-quart focus-mark rounded-sm ${t===i?"text-fg font-semibold":"text-fg-muted hover:text-fg"}`,children:i==="all"?"All":Oe(i)},i))})}function kt({selectedCount:t,allSelected:a,onToggleAll:i,onMarkRead:c,onMarkUnread:n,bulkInFlight:l,readOnly:m}){const b=r.useRef(null),h=t>0;r.useEffect(()=>{b.current!==null&&(b.current.indeterminate=h&&!a)},[h,a]);const d=l!==null,S=m?_:void 0;return e.jsxs("div",{className:"flex items-baseline justify-between gap-4 flex-wrap border-b border-rule pb-3",role:"region","aria-label":"bulk mail selection",children:[e.jsxs("label",{className:"flex items-baseline gap-2 text-label uppercase tracking-wider text-fg-muted cursor-pointer",children:[e.jsx("input",{ref:b,type:"checkbox",className:"h-3.5 w-3.5 translate-y-[2px] cursor-pointer accent-fg focus-mark",checked:a,onChange:i,"aria-label":"select all mail"}),e.jsx("span",{children:h?`${t} selected`:"Select all"})]}),h&&e.jsxs("div",{className:"flex items-baseline gap-3",children:[m&&e.jsx(ne,{}),e.jsx(M,{size:"sm",tone:"quiet",onClick:c,disabled:m||d,title:S,children:l==="read"?"Marking":"Mark read"}),e.jsx(M,{size:"sm",tone:"quiet",onClick:n,disabled:m||d,title:S,children:l==="unread"?"Marking":"Mark unread"})]})]})}function Se({limit:t,onLimitChange:a,onWindowChange:i,window:c}){return e.jsxs("div",{className:"flex items-baseline gap-3 flex-wrap",children:[e.jsxs("label",{className:"flex items-baseline gap-2 text-label uppercase tracking-wider text-fg-muted",children:[e.jsx("span",{children:"Window"}),e.jsx("select",{"aria-label":"Mail time window",value:c,onChange:n=>i(Ct(n.target.value)),className:"bg-transparent border border-rule rounded-sm px-2 py-1 text-label uppercase tracking-wider text-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40",children:Re.map(n=>e.jsx("option",{value:n,children:Mt(n)},n))})]}),e.jsxs("label",{className:"flex items-baseline gap-2 text-label uppercase tracking-wider text-fg-muted",children:[e.jsx("span",{children:"History"}),e.jsx("select",{"aria-label":"Mail history limit",value:t,onChange:n=>a(St(n.target.value)),className:"bg-transparent border border-rule rounded-sm px-2 py-1 text-label uppercase tracking-wider text-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40",children:Ie.map(n=>e.jsxs("option",{value:n,children:["Recent ",n]},n))})]})]})}function St(t){const a=Number(t);return Ie.includes(a)?a:Ae}function Ct(t){return Re.includes(t)?t:Le}function Mt(t){return t==="24h"?"Last 24h":t==="7d"?"Last 7d":"All time"}function At(t){const a=t?.trim();return a&&a.length>0?a:null}function Oe(t){return t.charAt(0).toUpperCase()+t.slice(1)}export{Ft as MailPage}; +import{j as e,r,w as re,K as L,M as qe,H as F,I as B,v as Ce,g as Me,z as ae,R as ne,S as se,B as M,i as _,a as Ue,J as Ye,N as Ae,O as Le,u as Ke,b as Ve,A as Ge,P as be,Q as Qe,T as Je,U as Re,V as Ie}from"./index-CJ6RRl2D.js";import{a as Xe,L as Ze,m as et}from"./projectOf-B27nlS9X.js";import{a as tt,r as je}from"./routeHighlight-B30gQO2o.js";import{u as at,F as st}from"./useListFilters-DQXiYJvO.js";import{T as rt}from"./Table-CU8DfQGc.js";import{M as _e,P as nt}from"./constants-BJUwiA6r.js";import{P as lt}from"./PageHeader-4s_Bnmfl.js";import{F as P}from"./Field-9mEFYSnz.js";import{f as it}from"./time-D9v0saHV.js";function q(t){const a=t.trim();if(a.length===0||!a.includes("/")&&!a.includes("\\"))return a;const i=a.split(/[\\/]/).filter(m=>m.length>0),c=i[i.length-1];if(c===void 0)return a;const n=i[i.length-2];if(n===void 0)return c;const l=c.startsWith(`${n}-`)?c.slice(n.length+1):c;return`${Xe(n)} · ${l}`}function ot({collapsed:t,onToggle:a,children:i,className:c="w-full flex items-baseline justify-between gap-4 focus-mark",glyphClassName:n}){return e.jsx("button",{type:"button",onClick:a,className:c,"aria-expanded":!t,children:i({glyph:e.jsx(ct,{collapsed:t,className:n??""})})})}function ct({collapsed:t,className:a=""}){return e.jsx("span",{"aria-hidden":!0,className:`inline-block text-fg-faint transition-transform duration-150 ease-out-quart ${a}`,style:{transform:t?"rotate(-90deg)":"rotate(0deg)"},children:"▾"})}function dt({project:t,count:a,collapsed:i,onToggle:c,collapsible:n=!0}){return n?e.jsx(ot,{collapsed:i,onToggle:c,className:"group flex items-baseline gap-2 w-full text-left focus-mark rounded-sm py-1",glyphClassName:"group-hover:text-fg-muted tnum w-3",children:({glyph:l})=>e.jsxs(e.Fragment,{children:[l,e.jsx("span",{className:"text-title font-medium text-fg group-hover:text-fg",children:t}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:a})]})}):e.jsxs("div",{role:"heading","aria-level":2,className:"flex items-baseline gap-2 py-1 text-label uppercase tracking-wider text-fg-faint",children:[e.jsx("span",{"aria-hidden":!0,children:"·"}),e.jsx("span",{children:t}),e.jsx("span",{"aria-hidden":!0,children:"·"})]})}function ut({groups:t,columns:a,rowKey:i,onToggleProject:c,onRowClick:n,rowProps:l,emptyMessage:m,perProjectEmpty:b,initialSort:h}){return t.length===0?e.jsx("p",{className:"py-10 text-center text-fg-muted italic",children:m}):e.jsx("div",{className:"space-y-8",children:t.map(d=>e.jsxs("section",{children:[e.jsx(dt,{project:d.project,count:d.totalInProject,collapsed:d.collapsed,onToggle:()=>c(d.projectKey),collapsible:d.collapsible}),!d.collapsed&&e.jsx(rt,{columns:a,rows:d.rows,rowKey:i,empty:b??"No items.",...n!==void 0?{onRowClick:n}:{},...l!==void 0?{rowProps:l}:{},...h!==void 0?{initialSort:h}:{}})]},d.projectKey))})}const ye="border-rule pb-6 border-b sm:shrink-0 sm:pr-6 sm:pb-0 sm:border-b-0 sm:border-r";function mt({buckets:t,loading:a,sessionsUnavailable:i,value:c,onChange:n,onReset:l,isOperator:m}){const[b,h]=r.useState(!1),[d,S]=r.useState(""),{operatorAlias:y,operatorWireAlias:u}=re(),A=L(c,y),x=r.useMemo(()=>{const f=d.trim().toLowerCase();return t.map(g=>({tier:g.tier,aliases:g.aliases.filter(v=>v.toLowerCase()===u?!1:f.length===0?!0:L(v,y).toLowerCase().includes(f)||v.toLowerCase().includes(f))})).filter(g=>g.aliases.length>0)},[t,d,y,u]),R=f=>{n(f),h(!1),S("")};return b?e.jsxs("aside",{className:`${ye} sm:w-64`,children:[e.jsx("button",{type:"button",onClick:()=>h(!1),"aria-expanded":!0,className:"text-label uppercase tracking-wider text-fg-muted hover:text-fg focus-mark rounded-sm",children:"▾ Agents"}),e.jsxs("div",{className:"mt-2 text-label uppercase tracking-wider text-fg-faint",children:[m?"Reading as":e.jsx("span",{className:"text-accent",children:"▲ Reading as"})," ",e.jsx("span",{className:`not-italic ${m?"text-fg-muted":"text-accent"}`,children:A})]}),e.jsx("div",{className:"mt-3 border-b border-rule pb-1",children:e.jsx("input",{type:"search",value:d,onChange:f=>S(f.target.value),placeholder:"Find an agent","aria-label":"Find an agent",autoFocus:!0,className:"w-full bg-transparent border-0 text-body text-fg placeholder:text-fg-faint focus:outline-none focus:ring-0 px-0 py-0.5"})}),e.jsxs("div",{className:"mt-3 max-h-[28rem] overflow-y-auto -mr-2 pr-2 space-y-4",children:[x.length===0?e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:a?"Loading aliases":"No agents match."}):x.map(f=>e.jsxs("div",{children:[e.jsx("div",{className:"text-label uppercase tracking-wider text-fg-faint mb-1",children:qe(f.tier)}),e.jsx("ul",{className:"space-y-0.5",children:f.aliases.map(g=>{const v=g.toLowerCase()===c.toLowerCase();return e.jsx("li",{children:e.jsx("button",{type:"button",onClick:()=>R(g),"aria-current":v,className:`block w-full text-left truncate text-body transition-colors duration-150 ease-out-quart focus-mark rounded-sm py-0.5 ${v?"text-fg font-semibold":"text-fg-muted hover:text-fg"}`,title:L(g,y),children:L(g,y)})},g)})})]},f.tier)),a&&x.length>0&&e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:"Loading more agents"}),!a&&i&&x.length>0&&(ft(x)?e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:"Agent list and mail history both unavailable."}):e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:"Agent list unavailable; showing mail-derived aliases only."}))]}),!m&&e.jsxs("div",{className:"mt-4 pt-3 border-t border-rule space-y-2",children:[e.jsx("button",{type:"button",onClick:l,className:"block text-label uppercase tracking-wider text-fg-muted hover:text-fg focus-mark underline decoration-dotted underline-offset-2 rounded-sm",children:"Back to operator"}),e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint italic",children:"Read-only. Sends always go from the operator."})]})]}):e.jsxs("aside",{className:`${ye} sm:w-44`,children:[e.jsx("button",{type:"button",onClick:()=>h(!0),"aria-expanded":!1,className:"text-label uppercase tracking-wider text-fg-muted hover:text-fg focus-mark rounded-sm",children:"▸ Agents"}),e.jsx("div",{className:"mt-4 text-label uppercase tracking-wider text-fg-faint",children:m?"Reading as":e.jsx("span",{className:"text-accent",children:"▲ Reading as"})}),e.jsx("div",{className:`mt-1 text-body truncate ${m?"text-fg":"text-accent font-medium"}`,title:A,children:A}),!m&&e.jsxs(e.Fragment,{children:[e.jsx("button",{type:"button",onClick:l,className:"mt-3 block text-label uppercase tracking-wider text-fg-muted hover:text-fg focus-mark underline decoration-dotted underline-offset-2 rounded-sm",children:"Back to operator"}),e.jsx("p",{className:"mt-2 text-label uppercase tracking-wider text-fg-faint italic",children:"Read-only. Sends go from the operator."})]})]})}function ft(t){let a=0;for(const i of t)if(a+=i.aliases.length,a>1)return!1;return a<=1}async function pt(t,a){await F().sendMail(B("send supervisor mail"),{...t,from:a})}async function ve(t){await F().markMailRead(B("mark supervisor mail read"),t.id,U(t))}async function we(t){await F().markMailUnread(B("mark supervisor mail unread"),t.id,U(t))}async function xt(t){await F().archiveMail(B("archive supervisor mail"),t.id,U(t))}async function ht(t,a,i){await F().replyMail(B("reply supervisor mail"),t.id,{...a,from:i},U(t))}function U(t){return t.rig===void 0||t.rig.length===0?void 0:{rig:t.rig}}function gt({open:t,onClose:a,onSent:i}){const{viewingAs:c}=Ce(),n=Me(),{operatorAlias:l,operatorWireAlias:m}=re(),[b,h]=r.useState(""),[d,S]=r.useState(""),[y,u]=r.useState(""),[A,x]=r.useState(!1),[R,f]=r.useState(null);r.useEffect(()=>{t||(h(""),S(""),u(""),f(null))},[t]);const g=r.useCallback(async()=>{if(!n){x(!0),f(null);try{await pt({to:b,subject:d,body:y},m),i()}catch(N){f(ae(N,"send failed"))}finally{x(!1)}}},[y,i,n,d,b,m]),v=!n&&c.isOperator&&b.length>0&&d.length>0&&y.length>0&&!A;return e.jsx(_e,{open:t,onClose:a,title:"New message",caption:"Sends from the operator. Reading-as has no effect on the sender.",widthClass:"max-w-2xl",footer:e.jsxs(e.Fragment,{children:[e.jsx(M,{tone:"quiet",size:"sm",onClick:a,children:"Cancel"}),e.jsx(M,{tone:"accent",size:"sm",disabled:!v,title:n?_:void 0,onClick:()=>{g()},children:A?"Sending":"Send"})]}),children:e.jsxs("div",{className:"space-y-4",children:[e.jsx(P,{label:"From",variant:"form",children:e.jsx("input",{type:"text",value:c.isOperator?L(l,l):`${L(l,l)} (reading-as does not change sender)`,disabled:!0,className:"w-full bg-transparent border-0 border-b border-rule pb-1 text-body text-fg-muted italic"})}),e.jsx(P,{label:"To (alias)",variant:"form",children:e.jsx("input",{type:"text",autoFocus:!0,value:b,onChange:N=>h(N.target.value),placeholder:"mayor, mechanic, scix-worker, …",className:"w-full bg-transparent border-0 border-b border-rule pb-1 text-body text-fg placeholder:text-fg-faint focus:border-accent focus:outline-none transition-colors"})}),e.jsx(P,{label:"Subject",variant:"form",children:e.jsx("input",{type:"text",value:d,onChange:N=>S(N.target.value),maxLength:200,className:"w-full bg-transparent border-0 border-b border-rule pb-1 text-body text-fg focus:border-accent focus:outline-none transition-colors"})}),e.jsx(P,{label:"Body",variant:"form",children:e.jsx("textarea",{value:y,onChange:N=>u(N.target.value),rows:10,maxLength:16*1024,className:"w-full bg-surface-tint border border-rule rounded-sm px-3 py-2 text-body text-fg focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40 resize-y"})}),n&&e.jsx(ne,{}),!c.isOperator&&e.jsx(se,{tone:"warn",label:`Reading as ${L(c.alias,l)}. Sends from this modal are structurally locked to the operator regardless.`}),R&&e.jsx(se,{tone:"stuck",label:R})]})})}function Ne({message:t,attentionSeverity:a=null}){return e.jsxs("article",{...bt(a),className:"space-y-3 pb-4 border-b border-rule last:border-0",children:[e.jsxs("header",{className:"flex items-baseline justify-between gap-3",children:[e.jsxs("div",{className:"text-label uppercase tracking-wider text-fg-muted truncate",children:[e.jsx("span",{className:"text-fg font-medium",children:q(t.from)}),e.jsx("span",{className:"mx-1.5 text-fg-faint",children:"→"}),e.jsx("span",{children:q(t.to)})]}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:jt(t.created_at)})]}),e.jsx("p",{className:"text-title font-semibold text-fg",children:t.subject}),e.jsx(se,{tone:"warn",label:nt}),e.jsx("pre",{className:"text-body whitespace-pre-wrap leading-relaxed text-fg overflow-x-auto",children:t.body})]})}function bt(t){return t===null?{}:{"data-attention-severity":t}}function jt(t){const a=Date.parse(t);return Number.isFinite(a)?new Date(a).toLocaleString(void 0,{month:"short",day:"numeric",hour:"numeric",minute:"2-digit"}):"·"}const ke=[{id:"unread",label:"unread",match:t=>!t.read},{id:"read",label:"read",match:t=>t.read}],yt={id:"needs-you",label:"needs you",match:t=>!t.read&&!Je(t.from)},vt=t=>[t.from,t.to,t.subject,t.rig,t.body.split(` +`)[0]],wt=1e3;function Ft(){const t=Ue(),a=Me(),i=re(),[c]=Ye(),n=At(c.get("message")),{viewingAs:l,setAlias:m,resetToOperator:b,aliasBuckets:h,aliasesLoading:d,sessionsUnavailable:S,loadAliases:y}=Ce(),[u,A]=r.useState(()=>n===null?"inbox":"all"),[x,R]=r.useState(()=>n===null?Ae:wt),[f,g]=r.useState(Le);r.useEffect(()=>{y()},[y]);const v=Ke(),{data:N,loading:le,error:Y,refresh:O}=Ve(`mail:${u}:${l.alias}:${i.operatorWireAlias}:${x}:${f}`,()=>Ge(u,l.alias,i,x,f,v)),j=r.useMemo(()=>N?.items??[],[N]),[ie,I]=r.useState(null);r.useEffect(()=>{Y&&I(Y)},[Y]);const[w,T]=r.useState(null),[K,H]=r.useState([]),[$e,oe]=r.useState(!1),V=r.useRef(null),[W,G]=r.useState(""),[E,ce]=r.useState(null),[Te,Q]=r.useState(!1),[$,z]=r.useState(()=>new Set),[Ee,de]=r.useState(null),J=r.useCallback(async s=>{if(T(s),H([]),G(""),I(null),!!s.thread_id){oe(!0);try{const o=await be(s.thread_id,l.alias,i,x);H(o.items)}catch(o){I(o instanceof Error?o.message:"thread failed")}finally{oe(!1)}}},[x,l.alias,i]);r.useEffect(()=>{if(n===null){V.current=null;return}if(V.current===n)return;const s=j.find(o=>o.id===n);s!==void 0&&(V.current=n,J(s))},[j,J,n]);const X=r.useCallback(async s=>{const o=w;if(o!==null&&!a){ce(s),I(null);try{if(s==="read")await ve(o),T({...o,read:!0});else if(s==="unread")await we(o),T({...o,read:!1});else if(s==="archive")await xt(o),T(null),H([]);else{const p=W.trim();if(p.length===0)return;if(await ht(o,{body:p},i.operatorWireAlias),G(""),o.thread_id){const De=await be(o.thread_id,l.alias,i,x);H(De.items)}}await O()}catch(p){I(ae(p,`${s} failed`))}finally{ce(null)}}},[x,a,O,W,w,l.alias,i]),ue=r.useMemo(()=>[{key:"from",label:"From",sortable:!0,sortValue:s=>q(s.from),render:s=>e.jsx("span",{className:"text-fg-muted",children:q(s.from)}),className:"w-48"},{key:"subject",label:"Subject",sortable:!0,sortValue:s=>s.subject,render:s=>e.jsxs("div",{className:"min-w-0",children:[e.jsx("p",{className:`truncate ${s.read?"text-fg-muted":"text-fg font-medium"}`,children:s.subject}),e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-faint mt-1 truncate",children:s.body.split(` +`)[0]??""})]})},{key:"created_at",label:"When",sortable:!0,sortValue:s=>s.created_at,render:s=>e.jsx("span",{className:"tnum text-fg-muted",children:it(s.created_at,v)}),className:"w-24",align:"right"}],[v]),D=r.useMemo(()=>L(l.alias,i.operatorAlias),[l.alias,i.operatorAlias]),Z=r.useMemo(()=>u==="inbox"&&l.isOperator?Qe(j).length:0,[u,j,l.isOperator]),Pe=r.useMemo(()=>{const s=u==="all"?"all mail":u==="inbox"?"inbox":"sent";if(j.length===0)return`${Oe(s)} empty for ${D}.`;const o=u==="sent"?0:j.filter(p=>!p.read).length;return u==="inbox"&&l.isOperator?o===0?`${j.length} in inbox, all read.`:Z>0?`${j.length} in inbox, ${Z} need you of ${o} unread.`:`${j.length} in inbox, ${o} unread, none need you.`:o>0?`${j.length} in ${s}, ${o} unread.`:`${j.length} in ${s}.`},[u,j,D,Z,l.isOperator]),me=r.useMemo(()=>l.isOperator?[yt,...ke]:ke,[l.isOperator]),k=at({viewKey:`mail:${u}`,rows:j,projectOf:et,searchOf:vt,chips:me}),fe=u!=="sent",C=r.useMemo(()=>k.groups.flatMap(s=>s.rows),[k.groups]),pe=r.useMemo(()=>C.reduce((s,o)=>$.has(o.id)?s+1:s,0),[C,$]),ee=C.length>0&&pe===C.length;r.useEffect(()=>{z(new Set)},[u,l.alias]);const xe=r.useCallback(s=>{z(o=>{const p=new Set(o);return p.has(s)?p.delete(s):p.add(s),p})},[]),Fe=r.useCallback(()=>{z(ee?new Set:new Set(C.map(s=>s.id)))},[ee,C]),he=r.useCallback(async s=>{if(a)return;const o=C.filter(p=>$.has(p.id)&&p.read!==s);if(o.length!==0){de(s?"read":"unread"),I(null);try{await Promise.all(o.map(p=>s?ve(p):we(p))),z(new Set)}catch(p){I(ae(p,`bulk mark ${s?"read":"unread"} failed`))}finally{de(null),await O()}}},[a,C,$,O]),Be=r.useMemo(()=>({key:"__select",label:"",className:"w-8",render:s=>e.jsx("input",{type:"checkbox",className:"h-3.5 w-3.5 translate-y-[2px] cursor-pointer accent-fg focus-mark",checked:$.has(s.id),onChange:()=>xe(s.id),onClick:o=>o.stopPropagation(),"aria-label":`select mail: ${s.subject}`})}),[$,xe]),He=fe?[Be,...ue]:ue,We=r.useMemo(()=>s=>tt(je(t,"mail",s.id)),[t]),ge=r.useCallback(s=>je(t,"mail",s.id),[t]),te=u==="sent"?[]:me,ze=a||w===null||W.trim().length===0||E!==null||!l.isOperator;return e.jsxs("section",{children:[e.jsx(lt,{title:"Mail",synopsis:Pe,meta:e.jsxs(e.Fragment,{children:[ie&&e.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:ie}),a&&e.jsx(ne,{}),e.jsx(M,{size:"sm",onClick:()=>Q(!0),disabled:a||!l.isOperator,title:a?_:l.isOperator?"Compose a new message (sends as the operator)":"Switch back to the operator to compose",children:"Compose"}),e.jsx(M,{size:"sm",onClick:()=>{O()},disabled:le,children:le?"Refreshing":"Refresh"})]})}),e.jsxs("div",{className:"flex flex-col gap-8 sm:flex-row sm:items-start",children:[e.jsx(mt,{buckets:h,loading:d,sessionsUnavailable:S,value:l.alias,onChange:m,onReset:b,isOperator:l.isOperator}),e.jsxs("div",{className:"flex-1 min-w-0",children:[e.jsx("div",{className:"mb-6",children:e.jsx(Nt,{box:u,onChange:A})}),e.jsxs("div",{className:"mb-6 space-y-3",children:[e.jsx(Ze,{value:k.search,onChange:k.setSearch,placeholder:"Search mail by sender, subject, rig",matchCount:k.totalMatches,totalCount:j.length,ariaLabel:"Search mail"}),te.length>0&&e.jsxs("div",{className:"flex items-baseline justify-between gap-4 flex-wrap",children:[e.jsx(st,{chips:te,activeIds:k.activeChipIds,onToggle:k.toggleChip,legend:"Read state"}),e.jsx(Se,{limit:x,onLimitChange:R,onWindowChange:g,window:f})]}),te.length===0&&e.jsx("div",{className:"flex justify-end",children:e.jsx(Se,{limit:x,onLimitChange:R,onWindowChange:g,window:f})})]}),fe&&C.length>0&&e.jsx("div",{className:"mb-6",children:e.jsx(kt,{selectedCount:pe,allSelected:ee,onToggleAll:Fe,onMarkRead:()=>{he(!0)},onMarkUnread:()=>{he(!1)},bulkInFlight:Ee,readOnly:a})}),e.jsx(ut,{groups:k.groups,columns:He,rowKey:s=>s.id,onToggleProject:k.toggleProject,onRowClick:s=>{J(s)},rowProps:We,emptyMessage:k.search.length>0||k.activeChipIds.size>0?"No messages match the current search or filter.":`${u==="inbox"?"Inbox":"Sent"} empty for ${D}.`,perProjectEmpty:"No messages in this project.",initialSort:{key:"created_at",dir:"desc"}})]})]}),e.jsx(_e,{open:w!==null,onClose:()=>T(null),title:w?.subject??"Thread",caption:`Reading as ${D}, ${K.length} message(s)`,widthClass:"max-w-3xl",footer:w===null?null:e.jsxs(e.Fragment,{children:[e.jsx(M,{tone:"quiet",size:"sm",title:a?_:void 0,disabled:a||E!==null,onClick:()=>{X(w.read?"unread":"read")},children:w.read?"Mark unread":"Mark read"}),e.jsx(M,{tone:"quiet",size:"sm",title:a?_:void 0,disabled:a||E!==null,onClick:()=>{X("archive")},children:E==="archive"?"Archiving":"Archive"}),e.jsx(M,{tone:"accent",size:"sm",title:a?_:void 0,disabled:ze,onClick:()=>{X("reply")},children:E==="reply"?"Replying":"Reply"})]}),children:e.jsxs("div",{className:"space-y-6",children:[$e?e.jsx("p",{className:"text-fg-muted italic",children:"Loading thread."}):K.length===0&&w?e.jsx(Ne,{message:w,attentionSeverity:ge(w)}):e.jsx("ol",{className:"space-y-6",children:K.map(s=>e.jsx("li",{children:e.jsx(Ne,{message:s,attentionSeverity:ge(s)})},s.id))}),w!==null&&e.jsx(P,{label:"Reply",variant:"form",children:e.jsx("textarea",{value:W,onChange:s=>G(s.target.value),rows:5,maxLength:16*1024,title:a?_:void 0,disabled:a||!l.isOperator,className:"w-full bg-surface-tint border border-rule rounded-sm px-3 py-2 text-body text-fg focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40 resize-y disabled:opacity-50"})})]})}),e.jsx(gt,{open:Te,onClose:()=>Q(!1),onSent:()=>{Q(!1),u==="sent"&&O()}})]})}function Nt({box:t,onChange:a}){return e.jsx("div",{className:"flex items-baseline gap-6",children:["inbox","sent","all"].map(i=>e.jsx("button",{type:"button",onClick:()=>a(i),className:`text-title transition-colors duration-150 ease-out-quart focus-mark rounded-sm ${t===i?"text-fg font-semibold":"text-fg-muted hover:text-fg"}`,children:i==="all"?"All":Oe(i)},i))})}function kt({selectedCount:t,allSelected:a,onToggleAll:i,onMarkRead:c,onMarkUnread:n,bulkInFlight:l,readOnly:m}){const b=r.useRef(null),h=t>0;r.useEffect(()=>{b.current!==null&&(b.current.indeterminate=h&&!a)},[h,a]);const d=l!==null,S=m?_:void 0;return e.jsxs("div",{className:"flex items-baseline justify-between gap-4 flex-wrap border-b border-rule pb-3",role:"region","aria-label":"bulk mail selection",children:[e.jsxs("label",{className:"flex items-baseline gap-2 text-label uppercase tracking-wider text-fg-muted cursor-pointer",children:[e.jsx("input",{ref:b,type:"checkbox",className:"h-3.5 w-3.5 translate-y-[2px] cursor-pointer accent-fg focus-mark",checked:a,onChange:i,"aria-label":"select all mail"}),e.jsx("span",{children:h?`${t} selected`:"Select all"})]}),h&&e.jsxs("div",{className:"flex items-baseline gap-3",children:[m&&e.jsx(ne,{}),e.jsx(M,{size:"sm",tone:"quiet",onClick:c,disabled:m||d,title:S,children:l==="read"?"Marking":"Mark read"}),e.jsx(M,{size:"sm",tone:"quiet",onClick:n,disabled:m||d,title:S,children:l==="unread"?"Marking":"Mark unread"})]})]})}function Se({limit:t,onLimitChange:a,onWindowChange:i,window:c}){return e.jsxs("div",{className:"flex items-baseline gap-3 flex-wrap",children:[e.jsxs("label",{className:"flex items-baseline gap-2 text-label uppercase tracking-wider text-fg-muted",children:[e.jsx("span",{children:"Window"}),e.jsx("select",{"aria-label":"Mail time window",value:c,onChange:n=>i(Ct(n.target.value)),className:"bg-transparent border border-rule rounded-sm px-2 py-1 text-label uppercase tracking-wider text-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40",children:Re.map(n=>e.jsx("option",{value:n,children:Mt(n)},n))})]}),e.jsxs("label",{className:"flex items-baseline gap-2 text-label uppercase tracking-wider text-fg-muted",children:[e.jsx("span",{children:"History"}),e.jsx("select",{"aria-label":"Mail history limit",value:t,onChange:n=>a(St(n.target.value)),className:"bg-transparent border border-rule rounded-sm px-2 py-1 text-label uppercase tracking-wider text-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/40",children:Ie.map(n=>e.jsxs("option",{value:n,children:["Recent ",n]},n))})]})]})}function St(t){const a=Number(t);return Ie.includes(a)?a:Ae}function Ct(t){return Re.includes(t)?t:Le}function Mt(t){return t==="24h"?"Last 24h":t==="7d"?"Last 7d":"All time"}function At(t){const a=t?.trim();return a&&a.length>0?a:null}function Oe(t){return t.charAt(0).toUpperCase()+t.slice(1)}export{Ft as MailPage}; diff --git a/internal/api/dashboardspa/dist/assets/PageHeader-D_D-jYn1.js b/internal/api/dashboardspa/dist/assets/PageHeader-4s_Bnmfl.js similarity index 89% rename from internal/api/dashboardspa/dist/assets/PageHeader-D_D-jYn1.js rename to internal/api/dashboardspa/dist/assets/PageHeader-4s_Bnmfl.js index ce721feec6..10500fa23a 100644 --- a/internal/api/dashboardspa/dist/assets/PageHeader-D_D-jYn1.js +++ b/internal/api/dashboardspa/dist/assets/PageHeader-4s_Bnmfl.js @@ -1 +1 @@ -import{j as e}from"./index-C20tCZFz.js";function d({title:t,synopsis:s,meta:a,className:r=""}){return e.jsxs("header",{className:`grid grid-cols-1 items-start gap-x-6 gap-y-4 mb-10 md:grid-cols-[minmax(0,1fr)_auto] md:items-end ${r}`,children:[e.jsxs("div",{className:"min-w-0 space-y-2",children:[e.jsx("h1",{className:"text-display font-semibold tracking-tighter text-fg leading-[1.05]",children:t}),s&&e.jsx("p",{className:"text-body text-fg-muted max-w-prose",children:s})]}),a&&e.jsx("div",{className:"flex flex-wrap items-center gap-4 text-label uppercase tracking-wider md:justify-end",children:a})]})}export{d as P}; +import{j as e}from"./index-CJ6RRl2D.js";function d({title:t,synopsis:s,meta:a,className:r=""}){return e.jsxs("header",{className:`grid grid-cols-1 items-start gap-x-6 gap-y-4 mb-10 md:grid-cols-[minmax(0,1fr)_auto] md:items-end ${r}`,children:[e.jsxs("div",{className:"min-w-0 space-y-2",children:[e.jsx("h1",{className:"text-display font-semibold tracking-tighter text-fg leading-[1.05]",children:t}),s&&e.jsx("p",{className:"text-body text-fg-muted max-w-prose",children:s})]}),a&&e.jsx("div",{className:"flex flex-wrap items-center gap-4 text-label uppercase tracking-wider md:justify-end",children:a})]})}export{d as P}; diff --git a/internal/api/dashboardspa/dist/assets/Runs-BCTFHOlQ.js b/internal/api/dashboardspa/dist/assets/Runs-C0j-IP1W.js similarity index 90% rename from internal/api/dashboardspa/dist/assets/Runs-BCTFHOlQ.js rename to internal/api/dashboardspa/dist/assets/Runs-C0j-IP1W.js index a9634b59ea..1db864aaea 100644 --- a/internal/api/dashboardspa/dist/assets/Runs-BCTFHOlQ.js +++ b/internal/api/dashboardspa/dist/assets/Runs-C0j-IP1W.js @@ -1 +1 @@ -import{j as e,L as B,a6 as O,r as x,a7 as D,a as M,a8 as U,K as z,u as V,B as w}from"./index-C20tCZFz.js";import{b as F,r as Y}from"./routeHighlight-B30gQO2o.js";import{P as K}from"./PageHeader-D_D-jYn1.js";import{S as Q,P as q}from"./SseIndicator-CeTTAF2S.js";import{f as _}from"./time-D9v0saHV.js";import{S as G}from"./StageLadder-B3yZa5o4.js";const f=8;function W(t){return t==="blocked"?"text-accent":t==="complete"?"text-fg-muted":"text-fg"}function I({lane:t,now:n,attentionSeverity:r=null,blocked:s}){const a=Object.entries(t.statusCounts).sort((l,c)=>k(l[0]).localeCompare(k(c[0]))),{className:i="",...d}=F(r);return e.jsxs("li",{...d,className:`py-4 transition-colors duration-150 ease-out-quart ${i}`,children:[e.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[e.jsx("span",{className:`text-label uppercase tracking-wider ${W(t.phase)}`,children:t.phaseLabel}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum tabular-nums",title:t.updatedAt.status==="available"?t.updatedAt.at:t.updatedAt.error,children:t.updatedAt.status==="available"?_(t.updatedAt.at,n):"·"})]}),e.jsx(B,{to:O(t.id,t.scope),className:"focus-mark mt-1 block text-body text-fg leading-snug hover:text-accent",children:t.title}),(t.external.status!=="unavailable"||t.formula.status==="known")&&e.jsxs("div",{className:"mt-1 flex items-baseline gap-x-4 gap-y-1 flex-wrap text-label",children:[t.external.status!=="unavailable"&&(t.external.status==="available"?e.jsx("a",{href:t.external.url,target:"_blank",rel:"noreferrer",className:"text-fg-muted uppercase tracking-wider hover:text-fg focus-mark",children:t.external.label}):e.jsx("span",{className:"text-fg-muted uppercase tracking-wider",children:t.external.label})),t.formula.status==="known"&&e.jsx("span",{className:"text-fg-faint tnum",children:t.formula.name})]}),e.jsx(G,{stages:t.stages,label:t.title}),e.jsxs("div",{className:"mt-2 flex items-baseline gap-x-4 gap-y-1 flex-wrap text-label",children:[e.jsx("span",{className:"text-fg-faint tnum",title:"run root bead",children:t.id}),t.activeAssignees.length>0&&e.jsxs("span",{className:"text-fg-muted lowercase tracking-normal",children:[e.jsx("span",{className:"uppercase tracking-wider text-fg-faint",children:"on "}),t.activeAssignees.join(", ")]}),a.length>0&&e.jsx("span",{className:"text-fg-faint uppercase tracking-wider tnum tabular-nums",children:a.map(([l,c])=>`${c} ${l.replace(/_/g," ")}`).join(" · ")})]}),s!==void 0&&e.jsxs("div",{className:"mt-2",children:[e.jsxs("p",{className:"text-body text-fg leading-snug",children:[e.jsx("span",{"aria-hidden":"true",className:"text-accent",children:"✕"})," ",s.reason]}),e.jsx("p",{className:"mt-1 text-body text-fg-muted leading-snug",children:s.remedy})]})]})}function k(t){return`${{blocked:"0",in_progress:"1",open:"2",closed:"3"}[t]??"9"}-${t}`}const X=[["prReview","PR"],["designReview","Design"],["bugfix","Bugfix"],["other","Other"]],C="runs-historical-section",S="runs-historical-list",R="runs-active-list",h=5;function J({source:t,now:n,showHistory:r,attentionSeverity:s}){if(t.status==="error")return e.jsxs("section",{children:[e.jsx(A,{summary:null}),e.jsx("p",{className:"mt-8 text-body text-fg-muted italic",children:`Run data unavailable: ${t.error}.`})]});const a=t.data;return e.jsxs("section",{children:[e.jsx(A,{summary:a}),e.jsx(Z,{summary:a,now:n,...s===void 0?{}:{attentionSeverity:s}}),e.jsx(se,{summary:a,now:n,...s===void 0?{}:{attentionSeverity:s}}),r&&e.jsx(ae,{summary:a,now:n,...s===void 0?{}:{attentionSeverity:s}})]})}function Z({summary:t,now:n,attentionSeverity:r}){const[s,a]=x.useState(!1);if(t.lanes.length===0){if(t.lanesPartial===!0)return e.jsx("p",{className:"mt-8 text-body text-fg-muted italic",children:"Run sources were partially unavailable; the lane set may be incomplete."});const l=t.totalHistorical>0?` (${t.totalHistorical} completed.)`:"";return e.jsx("p",{className:"mt-8 text-body text-fg-muted italic",children:`No active formula runs.${l}`})}const i=s?t.lanes:t.lanes.slice(0,f),d=ee(i);return e.jsxs(e.Fragment,{children:[e.jsx("div",{id:R,children:d.map(({rig:l,lanes:c})=>e.jsxs("div",{className:"mt-6",children:[e.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint",children:te(l)}),e.jsx(H,{lanes:c,now:n,...r===void 0?{}:{attentionSeverity:r}})]},l))}),t.lanes.length>f&&e.jsx("button",{type:"button",onClick:()=>a(l=>!l),"aria-expanded":s,"aria-controls":R,className:"mt-3 text-label uppercase tracking-wider text-fg-faint tnum hover:text-fg focus-mark",children:s?"Show fewer":`Show ${t.lanes.length-f} more runs`})]})}function H({lanes:t,now:n,attentionSeverity:r,listId:s}){return e.jsx("ol",{...s===void 0?{}:{id:s},className:"mt-3 divide-y divide-rule",children:t.map(a=>e.jsx(I,{lane:a,now:n,...r===void 0?{}:{attentionSeverity:r(a)}},a.id))})}function ee(t){const n=[],r=new Map;for(const s of t){const a=s.scope.status==="available"&&s.scope.kind==="rig"?s.scope.rootStoreRef:"city";let i=r.get(a);i===void 0&&(i=[],r.set(a,i),n.push(a)),i.push(s)}return n.map(s=>({rig:s,lanes:r.get(s)}))}function te(t){return t.replace(/^rig:/,"")}function se({summary:t,now:n,attentionSeverity:r}){const s=new Map(D(t.blockedLanes).map(a=>[a.id,a]));return s.size===0?null:e.jsxs("section",{"aria-label":"Blocked runs",className:"mt-12",children:[e.jsxs("h2",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:["Blocked (",s.size,")"]}),e.jsx("ol",{className:"mt-3 divide-y divide-rule",children:t.blockedLanes.map(a=>{const i=s.get(a.id);return e.jsx(I,{lane:a,now:n,...r===void 0?{}:{attentionSeverity:r(a)},...i===void 0?{}:{blocked:i}},a.id)})})]})}function ae({summary:t,now:n,attentionSeverity:r}){const[s,a]=x.useState(!1),i=t.historicalLanes,d=s?i:i.slice(0,h);return e.jsxs("section",{id:C,"aria-label":"Historical runs",className:"mt-12",children:[e.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Historical"}),i.length===0?e.jsx("p",{className:"mt-3 text-body text-fg-muted italic",children:"No completed runs in the current window."}):e.jsxs(e.Fragment,{children:[e.jsx(H,{lanes:d,now:n,listId:S,...r===void 0?{}:{attentionSeverity:r}}),i.length>h&&e.jsx("button",{type:"button",onClick:()=>a(l=>!l),"aria-expanded":s,"aria-controls":S,className:"mt-3 text-label uppercase tracking-wider text-fg-faint tnum hover:text-fg focus-mark",children:s?"Show fewer":`Show ${i.length-h} more`}),t.totalHistorical>i.length&&e.jsxs("p",{className:"mt-3 text-label uppercase tracking-wider text-fg-faint tnum",children:["Showing ",i.length," most-recent of ",t.totalHistorical]})]})]})}function A({summary:t}){const n=t?.runCounts.total??0,r=t?.runCounts.blocked??0;return e.jsx("header",{className:"space-y-2",children:e.jsxs("div",{className:"flex items-baseline gap-x-6 gap-y-2 flex-wrap",children:[e.jsx(g,{label:"Active",value:n,tone:"strong"}),X.map(([s,a])=>e.jsx(g,{label:a,value:t?.runCounts[s]??0,tone:"muted"},s)),r>0&&e.jsx(g,{label:"Blocked",value:r,tone:"muted"})]})})}function g({label:t,value:n,tone:r}){const s=r==="strong"?"text-fg":"text-fg-muted";return e.jsxs("div",{className:"flex flex-col",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint",children:t}),e.jsx("span",{className:`text-title tnum ${s}`,children:n})]})}const re=C,L="Phase grammar: intake, implementation, review, approval, finalization.",b="history",y="1";function xe(){const t=M(),{source:n,loading:r,error:s,refresh:a,sseState:i}=U(),[d,l]=z(),c=d.get(b)===y,j=V(),o=n??null,N=o?.status==="fresh"||o?.status==="fixture"||o?.status==="stale"?o.data:null,u=N?.totalHistorical??0,$=N?.lanesPartial===!0,P=x.useCallback(()=>{l(m=>{const p=new URLSearchParams(m);return c?p.delete(b):p.set(b,y),p},{replace:!1})},[c,l]),E=x.useCallback(m=>Y(t,"runs",m.id),[t]),T=ne(n),v=o?o.status==="fresh"?null:o.status==="fixture"?"fixture data":o.status==="error"?"live data unavailable":o.fetchedAt?`stale ${_(o.fetchedAt,j)} ago`:"stale":null;return e.jsxs("section",{children:[e.jsx(K,{title:"Formula Runs",synopsis:T,className:"md:items-start",meta:e.jsxs(e.Fragment,{children:[s&&e.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:s}),v!==null&&e.jsx("span",{className:`text-label uppercase tracking-wider tnum ${o?.status==="error"?"text-accent":"text-fg-faint"}`,children:v}),e.jsxs("div",{className:"grid w-full min-w-[18rem] grid-cols-[7rem_minmax(6.5rem,1fr)] items-center gap-x-4 gap-y-3 sm:w-[34rem] sm:grid-cols-[7rem_6.5rem_10rem_7rem]",children:[e.jsx(Q,{state:i}),e.jsx("span",{children:$?e.jsx(q,{glyph:"◐",label:"runs partial",title:"one or more rigs' recent runs were unavailable; the lane set may be incomplete"}):e.jsx("span",{"aria-hidden":"true",className:"invisible normal-case text-body text-warn",children:"runs partial"})}),e.jsx(w,{size:"sm",className:"w-full justify-center",onClick:P,disabled:!c&&u===0,"aria-expanded":c,...c?{"aria-controls":re}:{},"aria-label":c?"Hide historical formula runs.":u===0?"No completed formula runs in the current window.":`Show ${u} completed formula runs.`,children:c?"Hide history":u>0?`Show history (${u})`:"Show history"}),e.jsx(w,{size:"sm",className:"w-full justify-center",onClick:()=>{a()},disabled:r,children:r?"Refreshing":"Refresh"})]})]})}),n===void 0||o===null?e.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading formula runs."}):e.jsx(J,{source:o,now:j,showHistory:c,attentionSeverity:E})]})}function ne(t){return t===void 0?"Loading formula run lanes.":t.status!=="error"?`${t.data.totalActive} active runs across the supervisor's bead store. ${L}`:`Run counts unavailable: ${t.error}. ${L}`}export{xe as RunsPage}; +import{j as e,L as B,a5 as O,r as x,a6 as D,a as M,a7 as U,J as z,u as V,B as w}from"./index-CJ6RRl2D.js";import{b as F,r as Y}from"./routeHighlight-B30gQO2o.js";import{P as Q}from"./PageHeader-4s_Bnmfl.js";import{S as q,P as G}from"./SseIndicator-Cbaw_u69.js";import{f as _}from"./time-D9v0saHV.js";import{S as J}from"./StageLadder-DMyiD1Pv.js";const f=8;function K(t){return t==="blocked"?"text-accent":t==="complete"?"text-fg-muted":"text-fg"}function I({lane:t,now:n,attentionSeverity:r=null,blocked:s}){const a=Object.entries(t.statusCounts).sort((l,c)=>k(l[0]).localeCompare(k(c[0]))),{className:i="",...d}=F(r);return e.jsxs("li",{...d,className:`py-4 transition-colors duration-150 ease-out-quart ${i}`,children:[e.jsxs("div",{className:"flex items-baseline justify-between gap-4",children:[e.jsx("span",{className:`text-label uppercase tracking-wider ${K(t.phase)}`,children:t.phaseLabel}),e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum tabular-nums",title:t.updatedAt.status==="available"?t.updatedAt.at:t.updatedAt.error,children:t.updatedAt.status==="available"?_(t.updatedAt.at,n):"·"})]}),e.jsx(B,{to:O(t.id,t.scope),className:"focus-mark mt-1 block text-body text-fg leading-snug hover:text-accent",children:t.title}),(t.external.status!=="unavailable"||t.formula.status==="known")&&e.jsxs("div",{className:"mt-1 flex items-baseline gap-x-4 gap-y-1 flex-wrap text-label",children:[t.external.status!=="unavailable"&&(t.external.status==="available"?e.jsx("a",{href:t.external.url,target:"_blank",rel:"noreferrer",className:"text-fg-muted uppercase tracking-wider hover:text-fg focus-mark",children:t.external.label}):e.jsx("span",{className:"text-fg-muted uppercase tracking-wider",children:t.external.label})),t.formula.status==="known"&&e.jsx("span",{className:"text-fg-faint tnum",children:t.formula.name})]}),e.jsx(J,{stages:t.stages,label:t.title}),e.jsxs("div",{className:"mt-2 flex items-baseline gap-x-4 gap-y-1 flex-wrap text-label",children:[e.jsx("span",{className:"text-fg-faint tnum",title:"run root bead",children:t.id}),t.activeAssignees.length>0&&e.jsxs("span",{className:"text-fg-muted lowercase tracking-normal",children:[e.jsx("span",{className:"uppercase tracking-wider text-fg-faint",children:"on "}),t.activeAssignees.join(", ")]}),a.length>0&&e.jsx("span",{className:"text-fg-faint uppercase tracking-wider tnum tabular-nums",children:a.map(([l,c])=>`${c} ${l.replace(/_/g," ")}`).join(" · ")})]}),s!==void 0&&e.jsxs("div",{className:"mt-2",children:[e.jsxs("p",{className:"text-body text-fg leading-snug",children:[e.jsx("span",{"aria-hidden":"true",className:"text-accent",children:"✕"})," ",s.reason]}),e.jsx("p",{className:"mt-1 text-body text-fg-muted leading-snug",children:s.remedy})]})]})}function k(t){return`${{blocked:"0",in_progress:"1",open:"2",closed:"3"}[t]??"9"}-${t}`}const W=[["prReview","PR"],["designReview","Design"],["bugfix","Bugfix"],["other","Other"]],C="runs-historical-section",S="runs-historical-list",R="runs-active-list",h=5;function X({source:t,now:n,showHistory:r,attentionSeverity:s}){if(t.status==="error")return e.jsxs("section",{children:[e.jsx(A,{summary:null}),e.jsx("p",{className:"mt-8 text-body text-fg-muted italic",children:`Run data unavailable: ${t.error}.`})]});const a=t.data;return e.jsxs("section",{children:[e.jsx(A,{summary:a}),e.jsx(Z,{summary:a,now:n,...s===void 0?{}:{attentionSeverity:s}}),e.jsx(se,{summary:a,now:n,...s===void 0?{}:{attentionSeverity:s}}),r&&e.jsx(ae,{summary:a,now:n,...s===void 0?{}:{attentionSeverity:s}})]})}function Z({summary:t,now:n,attentionSeverity:r}){const[s,a]=x.useState(!1);if(t.lanes.length===0){if(t.lanesPartial===!0)return e.jsx("p",{className:"mt-8 text-body text-fg-muted italic",children:"Run sources were partially unavailable; the lane set may be incomplete."});const l=t.totalHistorical>0?` (${t.totalHistorical} completed.)`:"";return e.jsx("p",{className:"mt-8 text-body text-fg-muted italic",children:`No active formula runs.${l}`})}const i=s?t.lanes:t.lanes.slice(0,f),d=ee(i);return e.jsxs(e.Fragment,{children:[e.jsx("div",{id:R,children:d.map(({rig:l,lanes:c})=>e.jsxs("div",{className:"mt-6",children:[e.jsx("h3",{className:"text-label uppercase tracking-wider text-fg-faint",children:te(l)}),e.jsx(H,{lanes:c,now:n,...r===void 0?{}:{attentionSeverity:r}})]},l))}),t.lanes.length>f&&e.jsx("button",{type:"button",onClick:()=>a(l=>!l),"aria-expanded":s,"aria-controls":R,className:"mt-3 text-label uppercase tracking-wider text-fg-faint tnum hover:text-fg focus-mark",children:s?"Show fewer":`Show ${t.lanes.length-f} more runs`})]})}function H({lanes:t,now:n,attentionSeverity:r,listId:s}){return e.jsx("ol",{...s===void 0?{}:{id:s},className:"mt-3 divide-y divide-rule",children:t.map(a=>e.jsx(I,{lane:a,now:n,...r===void 0?{}:{attentionSeverity:r(a)}},a.id))})}function ee(t){const n=[],r=new Map;for(const s of t){const a=s.scope.status==="available"&&s.scope.kind==="rig"?s.scope.rootStoreRef:"city";let i=r.get(a);i===void 0&&(i=[],r.set(a,i),n.push(a)),i.push(s)}return n.map(s=>({rig:s,lanes:r.get(s)}))}function te(t){return t.replace(/^rig:/,"")}function se({summary:t,now:n,attentionSeverity:r}){const s=new Map(D(t.blockedLanes).map(a=>[a.id,a]));return s.size===0?null:e.jsxs("section",{"aria-label":"Blocked runs",className:"mt-12",children:[e.jsxs("h2",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:["Blocked (",s.size,")"]}),e.jsx("ol",{className:"mt-3 divide-y divide-rule",children:t.blockedLanes.map(a=>{const i=s.get(a.id);return e.jsx(I,{lane:a,now:n,...r===void 0?{}:{attentionSeverity:r(a)},...i===void 0?{}:{blocked:i}},a.id)})})]})}function ae({summary:t,now:n,attentionSeverity:r}){const[s,a]=x.useState(!1),i=t.historicalLanes,d=s?i:i.slice(0,h);return e.jsxs("section",{id:C,"aria-label":"Historical runs",className:"mt-12",children:[e.jsx("h2",{className:"text-label uppercase tracking-wider text-fg-faint",children:"Historical"}),i.length===0?e.jsx("p",{className:"mt-3 text-body text-fg-muted italic",children:"No completed runs in the current window."}):e.jsxs(e.Fragment,{children:[e.jsx(H,{lanes:d,now:n,listId:S,...r===void 0?{}:{attentionSeverity:r}}),i.length>h&&e.jsx("button",{type:"button",onClick:()=>a(l=>!l),"aria-expanded":s,"aria-controls":S,className:"mt-3 text-label uppercase tracking-wider text-fg-faint tnum hover:text-fg focus-mark",children:s?"Show fewer":`Show ${i.length-h} more`}),t.totalHistorical>i.length&&e.jsxs("p",{className:"mt-3 text-label uppercase tracking-wider text-fg-faint tnum",children:["Showing ",i.length," most-recent of ",t.totalHistorical]})]})]})}function A({summary:t}){const n=t?.runCounts.total??0,r=t?.runCounts.blocked??0;return e.jsx("header",{className:"space-y-2",children:e.jsxs("div",{className:"flex items-baseline gap-x-6 gap-y-2 flex-wrap",children:[e.jsx(g,{label:"Active",value:n,tone:"strong"}),W.map(([s,a])=>e.jsx(g,{label:a,value:t?.runCounts[s]??0,tone:"muted"},s)),r>0&&e.jsx(g,{label:"Blocked",value:r,tone:"muted"})]})})}function g({label:t,value:n,tone:r}){const s=r==="strong"?"text-fg":"text-fg-muted";return e.jsxs("div",{className:"flex flex-col",children:[e.jsx("span",{className:"text-label uppercase tracking-wider text-fg-faint",children:t}),e.jsx("span",{className:`text-title tnum ${s}`,children:n})]})}const re=C,L="Phase grammar: intake, implementation, review, approval, finalization.",b="history",y="1";function xe(){const t=M(),{source:n,loading:r,error:s,refresh:a,sseState:i}=U(),[d,l]=z(),c=d.get(b)===y,j=V(),o=n??null,N=o?.status==="fresh"||o?.status==="fixture"||o?.status==="stale"?o.data:null,u=N?.totalHistorical??0,$=N?.lanesPartial===!0,P=x.useCallback(()=>{l(m=>{const p=new URLSearchParams(m);return c?p.delete(b):p.set(b,y),p},{replace:!1})},[c,l]),E=x.useCallback(m=>Y(t,"runs",m.id),[t]),T=ne(n),v=o?o.status==="fresh"?null:o.status==="fixture"?"fixture data":o.status==="error"?"live data unavailable":o.fetchedAt?`stale ${_(o.fetchedAt,j)} ago`:"stale":null;return e.jsxs("section",{children:[e.jsx(Q,{title:"Formula Runs",synopsis:T,className:"md:items-start",meta:e.jsxs(e.Fragment,{children:[s&&e.jsx("span",{className:"normal-case text-body text-accent",role:"alert",children:s}),v!==null&&e.jsx("span",{className:`text-label uppercase tracking-wider tnum ${o?.status==="error"?"text-accent":"text-fg-faint"}`,children:v}),e.jsxs("div",{className:"grid w-full min-w-[18rem] grid-cols-[7rem_minmax(6.5rem,1fr)] items-center gap-x-4 gap-y-3 sm:w-[34rem] sm:grid-cols-[7rem_6.5rem_10rem_7rem]",children:[e.jsx(q,{state:i}),e.jsx("span",{children:$?e.jsx(G,{glyph:"◐",label:"runs partial",title:"one or more rigs' recent runs were unavailable; the lane set may be incomplete"}):e.jsx("span",{"aria-hidden":"true",className:"invisible normal-case text-body text-warn",children:"runs partial"})}),e.jsx(w,{size:"sm",className:"w-full justify-center",onClick:P,disabled:!c&&u===0,"aria-expanded":c,...c?{"aria-controls":re}:{},"aria-label":c?"Hide historical formula runs.":u===0?"No completed formula runs in the current window.":`Show ${u} completed formula runs.`,children:c?"Hide history":u>0?`Show history (${u})`:"Show history"}),e.jsx(w,{size:"sm",className:"w-full justify-center",onClick:()=>{a()},disabled:r,children:r?"Refreshing":"Refresh"})]})]})}),n===void 0||o===null?e.jsx("p",{className:"text-body text-fg-muted italic",children:"Loading formula runs."}):e.jsx(X,{source:o,now:j,showHistory:c,attentionSeverity:E})]})}function ne(t){return t===void 0?"Loading formula run lanes.":t.status!=="error"?`${t.data.totalActive} active runs across the supervisor's bead store. ${L}`:`Run counts unavailable: ${t.error}. ${L}`}export{xe as RunsPage}; diff --git a/internal/api/dashboardspa/dist/assets/SseIndicator-CeTTAF2S.js b/internal/api/dashboardspa/dist/assets/SseIndicator-Cbaw_u69.js similarity index 88% rename from internal/api/dashboardspa/dist/assets/SseIndicator-CeTTAF2S.js rename to internal/api/dashboardspa/dist/assets/SseIndicator-Cbaw_u69.js index 72f235e47d..070b250c3d 100644 --- a/internal/api/dashboardspa/dist/assets/SseIndicator-CeTTAF2S.js +++ b/internal/api/dashboardspa/dist/assets/SseIndicator-Cbaw_u69.js @@ -1 +1 @@ -import{j as a,S as t}from"./index-C20tCZFz.js";function i({label:n,title:e,show:r=!0,glyph:o}){return r?a.jsxs("span",{className:"normal-case text-body text-warn",role:"status",title:e,children:[o!==void 0&&a.jsxs("span",{"aria-hidden":"true",children:[o," "]}),n]}):null}function c({state:n}){const e=n==="open"?"ok":n==="connecting"||n==="degraded"?"warn":"stuck",r=n==="open"?"live":n==="connecting"?"connecting":n==="degraded"?"degraded":"offline";return a.jsx(t,{tone:e,label:r,title:`SSE stream: ${n}`,className:"w-28"})}export{i as P,c as S}; +import{j as a,S as t}from"./index-CJ6RRl2D.js";function i({label:n,title:e,show:r=!0,glyph:o}){return r?a.jsxs("span",{className:"normal-case text-body text-warn",role:"status",title:e,children:[o!==void 0&&a.jsxs("span",{"aria-hidden":"true",children:[o," "]}),n]}):null}function c({state:n}){const e=n==="open"?"ok":n==="connecting"||n==="degraded"?"warn":"stuck",r=n==="open"?"live":n==="connecting"?"connecting":n==="degraded"?"degraded":"offline";return a.jsx(t,{tone:e,label:r,title:`SSE stream: ${n}`,className:"w-28"})}export{i as P,c as S}; diff --git a/internal/api/dashboardspa/dist/assets/StageLadder-B3yZa5o4.js b/internal/api/dashboardspa/dist/assets/StageLadder-DMyiD1Pv.js similarity index 91% rename from internal/api/dashboardspa/dist/assets/StageLadder-B3yZa5o4.js rename to internal/api/dashboardspa/dist/assets/StageLadder-DMyiD1Pv.js index b84b993fb1..16cc0ee6c5 100644 --- a/internal/api/dashboardspa/dist/assets/StageLadder-B3yZa5o4.js +++ b/internal/api/dashboardspa/dist/assets/StageLadder-DMyiD1Pv.js @@ -1 +1 @@ -import{j as t}from"./index-C20tCZFz.js";const n={pending:"·",active:"⬣",complete:"◆",blocked:"✕"},c={pending:"text-fg-faint",active:"text-fg",complete:"text-fg-muted",blocked:"text-accent"},s={pending:"text-fg-muted",active:"text-fg",complete:"text-fg-muted",blocked:"text-accent"};function r({stages:a,label:l}){return a.length===0?null:t.jsx("ol",{className:"mt-2 flex items-baseline gap-x-2 flex-wrap","aria-label":`${l} stages`,children:a.map(e=>t.jsxs("li",{className:`text-label uppercase tracking-wider ${c[e.status]}`,title:`${e.label}: ${e.status}`,children:[t.jsx("span",{"aria-hidden":"true",children:n[e.status]})," ",t.jsx("span",{className:s[e.status],children:e.label})]},e.key))})}export{r as S}; +import{j as t}from"./index-CJ6RRl2D.js";const n={pending:"·",active:"⬣",complete:"◆",blocked:"✕"},c={pending:"text-fg-faint",active:"text-fg",complete:"text-fg-muted",blocked:"text-accent"},s={pending:"text-fg-muted",active:"text-fg",complete:"text-fg-muted",blocked:"text-accent"};function r({stages:a,label:l}){return a.length===0?null:t.jsx("ol",{className:"mt-2 flex items-baseline gap-x-2 flex-wrap","aria-label":`${l} stages`,children:a.map(e=>t.jsxs("li",{className:`text-label uppercase tracking-wider ${c[e.status]}`,title:`${e.label}: ${e.status}`,children:[t.jsx("span",{"aria-hidden":"true",children:n[e.status]})," ",t.jsx("span",{className:s[e.status],children:e.label})]},e.key))})}export{r as S}; diff --git a/internal/api/dashboardspa/dist/assets/Table-DojZJIvD.js b/internal/api/dashboardspa/dist/assets/Table-CU8DfQGc.js similarity index 96% rename from internal/api/dashboardspa/dist/assets/Table-DojZJIvD.js rename to internal/api/dashboardspa/dist/assets/Table-CU8DfQGc.js index 237ee0e82d..8d14dcd1a4 100644 --- a/internal/api/dashboardspa/dist/assets/Table-DojZJIvD.js +++ b/internal/api/dashboardspa/dist/assets/Table-CU8DfQGc.js @@ -1 +1 @@ -import{r as x,j as t}from"./index-C20tCZFz.js";function y({columns:i,rows:d,rowKey:f,onRowClick:c,rowProps:h,empty:b,initialSort:g}){const[n,p]=x.useState(g??null),m=x.useMemo(()=>{if(n===null)return d;const e=i.find(r=>r.key===n.key);if(!e||!e.sortable)return d;const s=e.sortValue??(r=>String(e.render(r)??"")),a=n.dir==="asc"?1:-1;return[...d].sort((r,u)=>{const l=s(r),o=s(u);return l===o?0:l==null?-a:o==null?a:lo?a:0})},[d,i,n]),N=e=>{p(s=>s?.key!==e?{key:e,dir:"asc"}:{key:e,dir:s.dir==="asc"?"desc":"asc"})};return t.jsx("div",{className:"overflow-x-auto",children:t.jsxs("table",{className:"w-full text-body tnum",children:[t.jsx("thead",{children:t.jsx("tr",{className:"border-b border-rule text-label uppercase tracking-wider text-fg-muted",children:i.map(e=>{const s=n?.key===e.key,a=e.align==="right"?"text-right":"text-left";return t.jsx("th",{scope:"col",className:`pb-3 pr-6 font-medium select-none ${a} ${e.className??""}`,children:e.sortable?t.jsxs("button",{type:"button",onClick:()=>N(e.key),className:"inline-flex items-center gap-1 hover:text-fg transition-colors duration-150 ease-out-quart focus-mark rounded-sm",children:[e.label,s&&t.jsx("span",{"aria-hidden":!0,className:"text-accent",children:n?.dir==="asc"?"↑":"↓"})]}):e.label},e.key)})})}),t.jsx("tbody",{children:m.length===0?t.jsx("tr",{children:t.jsx("td",{colSpan:i.length,className:"py-10 text-center text-fg-muted italic",children:b??"No data"})}):m.map(e=>{const{className:s="",...a}=h?.(e)??{};return x.createElement("tr",{...a,key:f(e),onClick:c?()=>c(e):void 0,className:`border-b border-rule transition-colors duration-150 ease-out-quart ${c?"cursor-pointer hover:bg-surface-tint":""} ${s}`},i.map(r=>{const u=r.align==="right"?"text-right":"text-left";return t.jsx("td",{className:`py-3 pr-6 align-baseline ${u} ${r.className??""}`,children:r.render(e)},r.key)}))})})]})})}export{y as T}; +import{r as x,j as t}from"./index-CJ6RRl2D.js";function y({columns:i,rows:d,rowKey:f,onRowClick:c,rowProps:h,empty:b,initialSort:g}){const[n,p]=x.useState(g??null),m=x.useMemo(()=>{if(n===null)return d;const e=i.find(r=>r.key===n.key);if(!e||!e.sortable)return d;const s=e.sortValue??(r=>String(e.render(r)??"")),a=n.dir==="asc"?1:-1;return[...d].sort((r,u)=>{const l=s(r),o=s(u);return l===o?0:l==null?-a:o==null?a:lo?a:0})},[d,i,n]),N=e=>{p(s=>s?.key!==e?{key:e,dir:"asc"}:{key:e,dir:s.dir==="asc"?"desc":"asc"})};return t.jsx("div",{className:"overflow-x-auto",children:t.jsxs("table",{className:"w-full text-body tnum",children:[t.jsx("thead",{children:t.jsx("tr",{className:"border-b border-rule text-label uppercase tracking-wider text-fg-muted",children:i.map(e=>{const s=n?.key===e.key,a=e.align==="right"?"text-right":"text-left";return t.jsx("th",{scope:"col",className:`pb-3 pr-6 font-medium select-none ${a} ${e.className??""}`,children:e.sortable?t.jsxs("button",{type:"button",onClick:()=>N(e.key),className:"inline-flex items-center gap-1 hover:text-fg transition-colors duration-150 ease-out-quart focus-mark rounded-sm",children:[e.label,s&&t.jsx("span",{"aria-hidden":!0,className:"text-accent",children:n?.dir==="asc"?"↑":"↓"})]}):e.label},e.key)})})}),t.jsx("tbody",{children:m.length===0?t.jsx("tr",{children:t.jsx("td",{colSpan:i.length,className:"py-10 text-center text-fg-muted italic",children:b??"No data"})}):m.map(e=>{const{className:s="",...a}=h?.(e)??{};return x.createElement("tr",{...a,key:f(e),onClick:c?()=>c(e):void 0,className:`border-b border-rule transition-colors duration-150 ease-out-quart ${c?"cursor-pointer hover:bg-surface-tint":""} ${s}`},i.map(r=>{const u=r.align==="right"?"text-right":"text-left";return t.jsx("td",{className:`py-3 pr-6 align-baseline ${u} ${r.className??""}`,children:r.render(e)},r.key)}))})})]})})}export{y as T}; diff --git a/internal/api/dashboardspa/dist/assets/agentReads-C0EYRgYm.js b/internal/api/dashboardspa/dist/assets/agentReads-C0EYRgYm.js deleted file mode 100644 index c861c03009..0000000000 --- a/internal/api/dashboardspa/dist/assets/agentReads-C0EYRgYm.js +++ /dev/null @@ -1 +0,0 @@ -import{I as e,J as i}from"./index-C20tCZFz.js";async function n(){const r=await e().listAgents(i("list supervisor agents"));return{...r,items:r.items??[]}}async function a(r){const t=r.trim();if(t.length===0)throw new Error("agent alias is required");return e().agentPrime(i("fetch supervisor agent prime"),t)}export{a as f,n as l}; diff --git a/internal/api/dashboardspa/dist/assets/agentReads-D6V_h6J8.js b/internal/api/dashboardspa/dist/assets/agentReads-D6V_h6J8.js new file mode 100644 index 0000000000..e15b905a18 --- /dev/null +++ b/internal/api/dashboardspa/dist/assets/agentReads-D6V_h6J8.js @@ -0,0 +1 @@ +import{H as t,I as i}from"./index-CJ6RRl2D.js";async function e(){const s=await t().listAgents(i("list supervisor agents"));return{...s,items:s.items??[]}}export{e as l}; diff --git a/internal/api/dashboardspa/dist/assets/constants-DBKWGg29.js b/internal/api/dashboardspa/dist/assets/constants-BJUwiA6r.js similarity index 95% rename from internal/api/dashboardspa/dist/assets/constants-DBKWGg29.js rename to internal/api/dashboardspa/dist/assets/constants-BJUwiA6r.js index 528dbd58d3..b8715e6542 100644 --- a/internal/api/dashboardspa/dist/assets/constants-DBKWGg29.js +++ b/internal/api/dashboardspa/dist/assets/constants-BJUwiA6r.js @@ -1 +1 @@ -import{r as o,j as e}from"./index-C20tCZFz.js";function m({open:s,onClose:t,title:i,caption:a,children:l,footer:n,widthClass:d="max-w-3xl"}){return o.useEffect(()=>{if(!s)return;const r=c=>{c.key==="Escape"&&t()};return document.addEventListener("keydown",r),()=>document.removeEventListener("keydown",r)},[s,t]),s?e.jsx("div",{role:"dialog","aria-modal":"true",className:"fixed inset-0 z-50 flex items-start sm:items-center justify-center bg-fg/30 p-3 sm:p-6",onClick:t,children:e.jsxs("div",{className:`w-full ${d} bg-surface border border-rule rounded-md flex flex-col max-h-[90vh]`,onClick:r=>r.stopPropagation(),children:[e.jsxs("div",{className:"flex items-start justify-between gap-3 px-5 py-4 border-b border-rule",children:[e.jsxs("div",{className:"min-w-0",children:[e.jsx("h2",{className:"text-title font-semibold text-fg truncate",children:i}),a&&e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-muted mt-1 truncate",children:a})]}),e.jsx("button",{type:"button",onClick:t,"aria-label":"Close",className:"text-fg-muted hover:text-fg transition-colors duration-150 ease-out-quart focus-mark text-lg leading-none px-1",children:"×"})]}),e.jsx("div",{className:"flex-1 overflow-auto p-5 text-body text-fg",children:l}),n&&e.jsx("div",{className:"border-t border-rule px-5 py-3 flex items-center justify-end gap-3",children:n})]})}):null}const u="Content is agent-generated and may contain misleading instructions.";export{m as M,u as P}; +import{r as o,j as e}from"./index-CJ6RRl2D.js";function m({open:s,onClose:t,title:i,caption:a,children:l,footer:n,widthClass:d="max-w-3xl"}){return o.useEffect(()=>{if(!s)return;const r=c=>{c.key==="Escape"&&t()};return document.addEventListener("keydown",r),()=>document.removeEventListener("keydown",r)},[s,t]),s?e.jsx("div",{role:"dialog","aria-modal":"true",className:"fixed inset-0 z-50 flex items-start sm:items-center justify-center bg-fg/30 p-3 sm:p-6",onClick:t,children:e.jsxs("div",{className:`w-full ${d} bg-surface border border-rule rounded-md flex flex-col max-h-[90vh]`,onClick:r=>r.stopPropagation(),children:[e.jsxs("div",{className:"flex items-start justify-between gap-3 px-5 py-4 border-b border-rule",children:[e.jsxs("div",{className:"min-w-0",children:[e.jsx("h2",{className:"text-title font-semibold text-fg truncate",children:i}),a&&e.jsx("p",{className:"text-label uppercase tracking-wider text-fg-muted mt-1 truncate",children:a})]}),e.jsx("button",{type:"button",onClick:t,"aria-label":"Close",className:"text-fg-muted hover:text-fg transition-colors duration-150 ease-out-quart focus-mark text-lg leading-none px-1",children:"×"})]}),e.jsx("div",{className:"flex-1 overflow-auto p-5 text-body text-fg",children:l}),n&&e.jsx("div",{className:"border-t border-rule px-5 py-3 flex items-center justify-end gap-3",children:n})]})}):null}const u="Content is agent-generated and may contain misleading instructions.";export{m as M,u as P}; diff --git a/internal/api/dashboardspa/dist/assets/index-C20tCZFz.js b/internal/api/dashboardspa/dist/assets/index-C20tCZFz.js deleted file mode 100644 index 6e8ff223ef..0000000000 --- a/internal/api/dashboardspa/dist/assets/index-C20tCZFz.js +++ /dev/null @@ -1,73 +0,0 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/Activity-DTboxwTI.js","assets/routeHighlight-B30gQO2o.js","assets/PageHeader-D_D-jYn1.js","assets/time-D9v0saHV.js","assets/useVisibleRefresh-D_HCcAAw.js","assets/Health-C5mLLJQ2.js","assets/format-fte2CeYD.js","assets/Agents-CF9gHKR0.js","assets/context-window-Cu9zl36t.js","assets/projectOf-CwPPScnJ.js","assets/constants-DBKWGg29.js","assets/SseIndicator-CeTTAF2S.js","assets/LiveSessionPeek-jm19JJ4Z.js","assets/Table-DojZJIvD.js","assets/agentReads-C0EYRgYm.js","assets/AgentDetail-DVT9Be-a.js","assets/BeadDetailModal-BtVrX_Fu.js","assets/Field-Dsl4x4KL.js","assets/AmbientHome-usE4zKNv.js","assets/Beads-DJjixOgD.js","assets/useListFilters-C0Eq1DLc.js","assets/Mail-767k9Nkh.js","assets/FormulaRunDetail-CFys0Xia.js","assets/StageLadder-B3yZa5o4.js","assets/Runs-BCTFHOlQ.js"])))=>i.map(i=>d[i]); -function Pg(t,r){for(var i=0;is[u]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const u of document.querySelectorAll('link[rel="modulepreload"]'))s(u);new MutationObserver(u=>{for(const p of u)if(p.type==="childList")for(const d of p.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&s(d)}).observe(document,{childList:!0,subtree:!0});function i(u){const p={};return u.integrity&&(p.integrity=u.integrity),u.referrerPolicy&&(p.referrerPolicy=u.referrerPolicy),u.crossOrigin==="use-credentials"?p.credentials="include":u.crossOrigin==="anonymous"?p.credentials="omit":p.credentials="same-origin",p}function s(u){if(u.ep)return;u.ep=!0;const p=i(u);fetch(u.href,p)}})();function qf(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var Al={exports:{}},Vo={},Ol={exports:{}},he={};var Mp;function Ng(){if(Mp)return he;Mp=1;var t=Symbol.for("react.element"),r=Symbol.for("react.portal"),i=Symbol.for("react.fragment"),s=Symbol.for("react.strict_mode"),u=Symbol.for("react.profiler"),p=Symbol.for("react.provider"),d=Symbol.for("react.context"),m=Symbol.for("react.forward_ref"),g=Symbol.for("react.suspense"),y=Symbol.for("react.memo"),E=Symbol.for("react.lazy"),S=Symbol.iterator;function T(z){return z===null||typeof z!="object"?null:(z=S&&z[S]||z["@@iterator"],typeof z=="function"?z:null)}var A={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},D=Object.assign,W={};function O(z,F,me){this.props=z,this.context=F,this.refs=W,this.updater=me||A}O.prototype.isReactComponent={},O.prototype.setState=function(z,F){if(typeof z!="object"&&typeof z!="function"&&z!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,z,F,"setState")},O.prototype.forceUpdate=function(z){this.updater.enqueueForceUpdate(this,z,"forceUpdate")};function H(){}H.prototype=O.prototype;function oe(z,F,me){this.props=z,this.context=F,this.refs=W,this.updater=me||A}var Q=oe.prototype=new H;Q.constructor=oe,D(Q,O.prototype),Q.isPureReactComponent=!0;var G=Array.isArray,ee=Object.prototype.hasOwnProperty,ue={current:null},de={key:!0,ref:!0,__self:!0,__source:!0};function pe(z,F,me){var ge,we={},xe=null,Ce=null;if(F!=null)for(ge in F.ref!==void 0&&(Ce=F.ref),F.key!==void 0&&(xe=""+F.key),F)ee.call(F,ge)&&!de.hasOwnProperty(ge)&&(we[ge]=F[ge]);var ke=arguments.length-2;if(ke===1)we.children=me;else if(1>>1,F=J[z];if(0>>1;zu(we,X))xeu(Ce,we)?(J[z]=Ce,J[xe]=X,z=xe):(J[z]=we,J[ge]=X,z=ge);else if(xeu(Ce,X))J[z]=Ce,J[xe]=X,z=xe;else break e}}return le}function u(J,le){var X=J.sortIndex-le.sortIndex;return X!==0?X:J.id-le.id}if(typeof performance=="object"&&typeof performance.now=="function"){var p=performance;t.unstable_now=function(){return p.now()}}else{var d=Date,m=d.now();t.unstable_now=function(){return d.now()-m}}var g=[],y=[],E=1,S=null,T=3,A=!1,D=!1,W=!1,O=typeof setTimeout=="function"?setTimeout:null,H=typeof clearTimeout=="function"?clearTimeout:null,oe=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function Q(J){for(var le=i(y);le!==null;){if(le.callback===null)s(y);else if(le.startTime<=J)s(y),le.sortIndex=le.expirationTime,r(g,le);else break;le=i(y)}}function G(J){if(W=!1,Q(J),!D)if(i(g)!==null)D=!0,vt(ee);else{var le=i(y);le!==null&&qe(G,le.startTime-J)}}function ee(J,le){D=!1,W&&(W=!1,H(pe),pe=-1),A=!0;var X=T;try{for(Q(le),S=i(g);S!==null&&(!(S.expirationTime>le)||J&&!Ze());){var z=S.callback;if(typeof z=="function"){S.callback=null,T=S.priorityLevel;var F=z(S.expirationTime<=le);le=t.unstable_now(),typeof F=="function"?S.callback=F:S===i(g)&&s(g),Q(le)}else s(g);S=i(g)}if(S!==null)var me=!0;else{var ge=i(y);ge!==null&&qe(G,ge.startTime-le),me=!1}return me}finally{S=null,T=X,A=!1}}var ue=!1,de=null,pe=-1,Re=5,ye=-1;function Ze(){return!(t.unstable_now()-yeJ||125z?(J.sortIndex=X,r(y,J),i(g)===null&&J===i(y)&&(W?(H(pe),pe=-1):W=!0,qe(G,X-z))):(J.sortIndex=F,r(g,J),D||A||(D=!0,vt(ee))),J},t.unstable_shouldYield=Ze,t.unstable_wrapCallback=function(J){var le=T;return function(){var X=T;T=le;try{return J.apply(this,arguments)}finally{T=X}}}})(Ll)),Ll}var Vp;function Lg(){return Vp||(Vp=1,$l.exports=$g()),$l.exports}var Wp;function Dg(){if(Wp)return xt;Wp=1;var t=iu(),r=Lg();function i(e){for(var n="https://reactjs.org/docs/error-decoder.html?invariant="+e,a=1;a"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),g=Object.prototype.hasOwnProperty,y=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,E={},S={};function T(e){return g.call(S,e)?!0:g.call(E,e)?!1:y.test(e)?S[e]=!0:(E[e]=!0,!1)}function A(e,n,a,l){if(a!==null&&a.type===0)return!1;switch(typeof n){case"function":case"symbol":return!0;case"boolean":return l?!1:a!==null?!a.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function D(e,n,a,l){if(n===null||typeof n>"u"||A(e,n,a,l))return!0;if(l)return!1;if(a!==null)switch(a.type){case 3:return!n;case 4:return n===!1;case 5:return isNaN(n);case 6:return isNaN(n)||1>n}return!1}function W(e,n,a,l,c,f,v){this.acceptsBooleans=n===2||n===3||n===4,this.attributeName=l,this.attributeNamespace=c,this.mustUseProperty=a,this.propertyName=e,this.type=n,this.sanitizeURL=f,this.removeEmptyString=v}var O={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){O[e]=new W(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var n=e[0];O[n]=new W(n,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){O[e]=new W(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){O[e]=new W(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){O[e]=new W(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){O[e]=new W(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){O[e]=new W(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){O[e]=new W(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){O[e]=new W(e,5,!1,e.toLowerCase(),null,!1,!1)});var H=/[\-:]([a-z])/g;function oe(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var n=e.replace(H,oe);O[n]=new W(n,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var n=e.replace(H,oe);O[n]=new W(n,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var n=e.replace(H,oe);O[n]=new W(n,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){O[e]=new W(e,1,!1,e.toLowerCase(),null,!1,!1)}),O.xlinkHref=new W("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){O[e]=new W(e,1,!1,e.toLowerCase(),null,!0,!0)});function Q(e,n,a,l){var c=O.hasOwnProperty(n)?O[n]:null;(c!==null?c.type!==0:l||!(2_||c[v]!==f[_]){var I=` -`+c[v].replace(" at new "," at ");return e.displayName&&I.includes("")&&(I=I.replace("",e.displayName)),I}while(1<=v&&0<=_);break}}}finally{me=!1,Error.prepareStackTrace=a}return(e=e?e.displayName||e.name:"")?F(e):""}function we(e){switch(e.tag){case 5:return F(e.type);case 16:return F("Lazy");case 13:return F("Suspense");case 19:return F("SuspenseList");case 0:case 2:case 15:return e=ge(e.type,!1),e;case 11:return e=ge(e.type.render,!1),e;case 1:return e=ge(e.type,!0),e;default:return""}}function xe(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case de:return"Fragment";case ue:return"Portal";case Re:return"Profiler";case pe:return"StrictMode";case et:return"Suspense";case Qe:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Ze:return(e.displayName||"Context")+".Consumer";case ye:return(e._context.displayName||"Context")+".Provider";case Ke:var n=e.render;return e=e.displayName,e||(e=n.displayName||n.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case kt:return n=e.displayName||null,n!==null?n:xe(e.type)||"Memo";case vt:n=e._payload,e=e._init;try{return xe(e(n))}catch{}}return null}function Ce(e){var n=e.type;switch(e.tag){case 24:return"Cache";case 9:return(n.displayName||"Context")+".Consumer";case 10:return(n._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=n.render,e=e.displayName||e.name||"",n.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return n;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return xe(n);case 8:return n===pe?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof n=="function")return n.displayName||n.name||null;if(typeof n=="string")return n}return null}function ke(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function Ae(e){var n=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(n==="checkbox"||n==="radio")}function bt(e){var n=Ae(e)?"checked":"value",a=Object.getOwnPropertyDescriptor(e.constructor.prototype,n),l=""+e[n];if(!e.hasOwnProperty(n)&&typeof a<"u"&&typeof a.get=="function"&&typeof a.set=="function"){var c=a.get,f=a.set;return Object.defineProperty(e,n,{configurable:!0,get:function(){return c.call(this)},set:function(v){l=""+v,f.call(this,v)}}),Object.defineProperty(e,n,{enumerable:a.enumerable}),{getValue:function(){return l},setValue:function(v){l=""+v},stopTracking:function(){e._valueTracker=null,delete e[n]}}}}function ri(e){e._valueTracker||(e._valueTracker=bt(e))}function Wu(e){if(!e)return!1;var n=e._valueTracker;if(!n)return!0;var a=n.getValue(),l="";return e&&(l=Ae(e)?e.checked?"true":"false":e.value),e=l,e!==a?(n.setValue(e),!0):!1}function oi(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Fa(e,n){var a=n.checked;return X({},n,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:a??e._wrapperState.initialChecked})}function Hu(e,n){var a=n.defaultValue==null?"":n.defaultValue,l=n.checked!=null?n.checked:n.defaultChecked;a=ke(n.value!=null?n.value:a),e._wrapperState={initialChecked:l,initialValue:a,controlled:n.type==="checkbox"||n.type==="radio"?n.checked!=null:n.value!=null}}function Gu(e,n){n=n.checked,n!=null&&Q(e,"checked",n,!1)}function Ua(e,n){Gu(e,n);var a=ke(n.value),l=n.type;if(a!=null)l==="number"?(a===0&&e.value===""||e.value!=a)&&(e.value=""+a):e.value!==""+a&&(e.value=""+a);else if(l==="submit"||l==="reset"){e.removeAttribute("value");return}n.hasOwnProperty("value")?Za(e,n.type,a):n.hasOwnProperty("defaultValue")&&Za(e,n.type,ke(n.defaultValue)),n.checked==null&&n.defaultChecked!=null&&(e.defaultChecked=!!n.defaultChecked)}function Ju(e,n,a){if(n.hasOwnProperty("value")||n.hasOwnProperty("defaultValue")){var l=n.type;if(!(l!=="submit"&&l!=="reset"||n.value!==void 0&&n.value!==null))return;n=""+e._wrapperState.initialValue,a||n===e.value||(e.value=n),e.defaultValue=n}a=e.name,a!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,a!==""&&(e.name=a)}function Za(e,n,a){(n!=="number"||oi(e.ownerDocument)!==e)&&(a==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+a&&(e.defaultValue=""+a))}var io=Array.isArray;function _r(e,n,a,l){if(e=e.options,n){n={};for(var c=0;c"+n.valueOf().toString()+"",n=ii.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;n.firstChild;)e.appendChild(n.firstChild)}});function ao(e,n){if(n){var a=e.firstChild;if(a&&a===e.lastChild&&a.nodeType===3){a.nodeValue=n;return}}e.textContent=n}var so={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},jv=["Webkit","ms","Moz","O"];Object.keys(so).forEach(function(e){jv.forEach(function(n){n=n+e.charAt(0).toUpperCase()+e.substring(1),so[n]=so[e]})});function tc(e,n,a){return n==null||typeof n=="boolean"||n===""?"":a||typeof n!="number"||n===0||so.hasOwnProperty(e)&&so[e]?(""+n).trim():n+"px"}function nc(e,n){e=e.style;for(var a in n)if(n.hasOwnProperty(a)){var l=a.indexOf("--")===0,c=tc(a,n[a],l);a==="float"&&(a="cssFloat"),l?e.setProperty(a,c):e[a]=c}}var $v=X({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Wa(e,n){if(n){if($v[e]&&(n.children!=null||n.dangerouslySetInnerHTML!=null))throw Error(i(137,e));if(n.dangerouslySetInnerHTML!=null){if(n.children!=null)throw Error(i(60));if(typeof n.dangerouslySetInnerHTML!="object"||!("__html"in n.dangerouslySetInnerHTML))throw Error(i(61))}if(n.style!=null&&typeof n.style!="object")throw Error(i(62))}}function Ha(e,n){if(e.indexOf("-")===-1)return typeof n.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Ga=null;function Ja(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Ka=null,wr=null,xr=null;function rc(e){if(e=Bo(e)){if(typeof Ka!="function")throw Error(i(280));var n=e.stateNode;n&&(n=Ci(n),Ka(e.stateNode,e.type,n))}}function oc(e){wr?xr?xr.push(e):xr=[e]:wr=e}function ic(){if(wr){var e=wr,n=xr;if(xr=wr=null,rc(e),n)for(e=0;e>>=0,e===0?32:31-(Gv(e)/Jv|0)|0}var ci=64,di=4194304;function po(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function pi(e,n){var a=e.pendingLanes;if(a===0)return 0;var l=0,c=e.suspendedLanes,f=e.pingedLanes,v=a&268435455;if(v!==0){var _=v&~c;_!==0?l=po(_):(f&=v,f!==0&&(l=po(f)))}else v=a&~c,v!==0?l=po(v):f!==0&&(l=po(f));if(l===0)return 0;if(n!==0&&n!==l&&(n&c)===0&&(c=l&-l,f=n&-n,c>=f||c===16&&(f&4194240)!==0))return n;if((l&4)!==0&&(l|=a&16),n=e.entangledLanes,n!==0)for(e=e.entanglements,n&=l;0a;a++)n.push(e);return n}function fo(e,n,a){e.pendingLanes|=n,n!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,n=31-Ut(n),e[n]=a}function Xv(e,n){var a=e.pendingLanes&~n;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=n,e.mutableReadLanes&=n,e.entangledLanes&=n,n=e.entanglements;var l=e.eventTimes;for(e=e.expirationTimes;0=xo),Nc=" ",Ac=!1;function Oc(e,n){switch(e){case"keyup":return zh.indexOf(n.keyCode)!==-1;case"keydown":return n.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function jc(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Sr=!1;function Th(e,n){switch(e){case"compositionend":return jc(n);case"keypress":return n.which!==32?null:(Ac=!0,Nc);case"textInput":return e=n.data,e===Nc&&Ac?null:e;default:return null}}function Bh(e,n){if(Sr)return e==="compositionend"||!ms&&Oc(e,n)?(e=zc(),gi=ls=bn=null,Sr=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(n.ctrlKey||n.altKey||n.metaKey)||n.ctrlKey&&n.altKey){if(n.char&&1=n)return{node:a,offset:n-e};e=l}e:{for(;a;){if(a.nextSibling){a=a.nextSibling;break e}a=a.parentNode}a=void 0}a=Zc(a)}}function Vc(e,n){return e&&n?e===n?!0:e&&e.nodeType===3?!1:n&&n.nodeType===3?Vc(e,n.parentNode):"contains"in e?e.contains(n):e.compareDocumentPosition?!!(e.compareDocumentPosition(n)&16):!1:!1}function Wc(){for(var e=window,n=oi();n instanceof e.HTMLIFrameElement;){try{var a=typeof n.contentWindow.location.href=="string"}catch{a=!1}if(a)e=n.contentWindow;else break;n=oi(e.document)}return n}function gs(e){var n=e&&e.nodeName&&e.nodeName.toLowerCase();return n&&(n==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||n==="textarea"||e.contentEditable==="true")}function Dh(e){var n=Wc(),a=e.focusedElem,l=e.selectionRange;if(n!==a&&a&&a.ownerDocument&&Vc(a.ownerDocument.documentElement,a)){if(l!==null&&gs(a)){if(n=l.start,e=l.end,e===void 0&&(e=n),"selectionStart"in a)a.selectionStart=n,a.selectionEnd=Math.min(e,a.value.length);else if(e=(n=a.ownerDocument||document)&&n.defaultView||window,e.getSelection){e=e.getSelection();var c=a.textContent.length,f=Math.min(l.start,c);l=l.end===void 0?f:Math.min(l.end,c),!e.extend&&f>l&&(c=l,l=f,f=c),c=qc(a,f);var v=qc(a,l);c&&v&&(e.rangeCount!==1||e.anchorNode!==c.node||e.anchorOffset!==c.offset||e.focusNode!==v.node||e.focusOffset!==v.offset)&&(n=n.createRange(),n.setStart(c.node,c.offset),e.removeAllRanges(),f>l?(e.addRange(n),e.extend(v.node,v.offset)):(n.setEnd(v.node,v.offset),e.addRange(n)))}}for(n=[],e=a;e=e.parentNode;)e.nodeType===1&&n.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof a.focus=="function"&&a.focus(),a=0;a=document.documentMode,kr=null,ys=null,ko=null,_s=!1;function Hc(e,n,a){var l=a.window===a?a.document:a.nodeType===9?a:a.ownerDocument;_s||kr==null||kr!==oi(l)||(l=kr,"selectionStart"in l&&gs(l)?l={start:l.selectionStart,end:l.selectionEnd}:(l=(l.ownerDocument&&l.ownerDocument.defaultView||window).getSelection(),l={anchorNode:l.anchorNode,anchorOffset:l.anchorOffset,focusNode:l.focusNode,focusOffset:l.focusOffset}),ko&&So(ko,l)||(ko=l,l=ki(ys,"onSelect"),0Br||(e.current=Rs[Br],Rs[Br]=null,Br--)}function Te(e,n){Br++,Rs[Br]=e.current,e.current=n}var Bn={},st=Tn(Bn),ht=Tn(!1),tr=Bn;function Rr(e,n){var a=e.type.contextTypes;if(!a)return Bn;var l=e.stateNode;if(l&&l.__reactInternalMemoizedUnmaskedChildContext===n)return l.__reactInternalMemoizedMaskedChildContext;var c={},f;for(f in a)c[f]=n[f];return l&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=n,e.__reactInternalMemoizedMaskedChildContext=c),c}function gt(e){return e=e.childContextTypes,e!=null}function Ti(){Ne(ht),Ne(st)}function ld(e,n,a){if(st.current!==Bn)throw Error(i(168));Te(st,n),Te(ht,a)}function ud(e,n,a){var l=e.stateNode;if(n=n.childContextTypes,typeof l.getChildContext!="function")return a;l=l.getChildContext();for(var c in l)if(!(c in n))throw Error(i(108,Ce(e)||"Unknown",c));return X({},a,l)}function Bi(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Bn,tr=st.current,Te(st,e),Te(ht,ht.current),!0}function cd(e,n,a){var l=e.stateNode;if(!l)throw Error(i(169));a?(e=ud(e,n,tr),l.__reactInternalMemoizedMergedChildContext=e,Ne(ht),Ne(st),Te(st,e)):Ne(ht),Te(ht,a)}var sn=null,Ri=!1,Ps=!1;function dd(e){sn===null?sn=[e]:sn.push(e)}function Qh(e){Ri=!0,dd(e)}function Rn(){if(!Ps&&sn!==null){Ps=!0;var e=0,n=be;try{var a=sn;for(be=1;e>=v,c-=v,ln=1<<32-Ut(n)+c|a<ce?(rt=se,se=null):rt=se.sibling;var Ee=M(C,se,B[ce],V);if(Ee===null){se===null&&(se=rt);break}e&&se&&Ee.alternate===null&&n(C,se),k=f(Ee,k,ce),ae===null?re=Ee:ae.sibling=Ee,ae=Ee,se=rt}if(ce===B.length)return a(C,se),Oe&&rr(C,ce),re;if(se===null){for(;cece?(rt=se,se=null):rt=se.sibling;var Mn=M(C,se,Ee.value,V);if(Mn===null){se===null&&(se=rt);break}e&&se&&Mn.alternate===null&&n(C,se),k=f(Mn,k,ce),ae===null?re=Mn:ae.sibling=Mn,ae=Mn,se=rt}if(Ee.done)return a(C,se),Oe&&rr(C,ce),re;if(se===null){for(;!Ee.done;ce++,Ee=B.next())Ee=q(C,Ee.value,V),Ee!==null&&(k=f(Ee,k,ce),ae===null?re=Ee:ae.sibling=Ee,ae=Ee);return Oe&&rr(C,ce),re}for(se=l(C,se);!Ee.done;ce++,Ee=B.next())Ee=K(se,C,ce,Ee.value,V),Ee!==null&&(e&&Ee.alternate!==null&&se.delete(Ee.key===null?ce:Ee.key),k=f(Ee,k,ce),ae===null?re=Ee:ae.sibling=Ee,ae=Ee);return e&&se.forEach(function(Rg){return n(C,Rg)}),Oe&&rr(C,ce),re}function He(C,k,B,V){if(typeof B=="object"&&B!==null&&B.type===de&&B.key===null&&(B=B.props.children),typeof B=="object"&&B!==null){switch(B.$$typeof){case ee:e:{for(var re=B.key,ae=k;ae!==null;){if(ae.key===re){if(re=B.type,re===de){if(ae.tag===7){a(C,ae.sibling),k=c(ae,B.props.children),k.return=C,C=k;break e}}else if(ae.elementType===re||typeof re=="object"&&re!==null&&re.$$typeof===vt&&gd(re)===ae.type){a(C,ae.sibling),k=c(ae,B.props),k.ref=Ro(C,ae,B),k.return=C,C=k;break e}a(C,ae);break}else n(C,ae);ae=ae.sibling}B.type===de?(k=dr(B.props.children,C.mode,V,B.key),k.return=C,C=k):(V=ia(B.type,B.key,B.props,null,C.mode,V),V.ref=Ro(C,k,B),V.return=C,C=V)}return v(C);case ue:e:{for(ae=B.key;k!==null;){if(k.key===ae)if(k.tag===4&&k.stateNode.containerInfo===B.containerInfo&&k.stateNode.implementation===B.implementation){a(C,k.sibling),k=c(k,B.children||[]),k.return=C,C=k;break e}else{a(C,k);break}else n(C,k);k=k.sibling}k=Tl(B,C.mode,V),k.return=C,C=k}return v(C);case vt:return ae=B._init,He(C,k,ae(B._payload),V)}if(io(B))return te(C,k,B,V);if(le(B))return ne(C,k,B,V);Oi(C,B)}return typeof B=="string"&&B!==""||typeof B=="number"?(B=""+B,k!==null&&k.tag===6?(a(C,k.sibling),k=c(k,B),k.return=C,C=k):(a(C,k),k=Cl(B,C.mode,V),k.return=C,C=k),v(C)):a(C,k)}return He}var Or=yd(!0),_d=yd(!1),ji=Tn(null),$i=null,jr=null,Ls=null;function Ds(){Ls=jr=$i=null}function Ms(e){var n=ji.current;Ne(ji),e._currentValue=n}function Fs(e,n,a){for(;e!==null;){var l=e.alternate;if((e.childLanes&n)!==n?(e.childLanes|=n,l!==null&&(l.childLanes|=n)):l!==null&&(l.childLanes&n)!==n&&(l.childLanes|=n),e===a)break;e=e.return}}function $r(e,n){$i=e,Ls=jr=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&n)!==0&&(yt=!0),e.firstContext=null)}function jt(e){var n=e._currentValue;if(Ls!==e)if(e={context:e,memoizedValue:n,next:null},jr===null){if($i===null)throw Error(i(308));jr=e,$i.dependencies={lanes:0,firstContext:e}}else jr=jr.next=e;return n}var or=null;function Us(e){or===null?or=[e]:or.push(e)}function wd(e,n,a,l){var c=n.interleaved;return c===null?(a.next=a,Us(n)):(a.next=c.next,c.next=a),n.interleaved=a,cn(e,l)}function cn(e,n){e.lanes|=n;var a=e.alternate;for(a!==null&&(a.lanes|=n),a=e,e=e.return;e!==null;)e.childLanes|=n,a=e.alternate,a!==null&&(a.childLanes|=n),a=e,e=e.return;return a.tag===3?a.stateNode:null}var Pn=!1;function Zs(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function xd(e,n){e=e.updateQueue,n.updateQueue===e&&(n.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function dn(e,n){return{eventTime:e,lane:n,tag:0,payload:null,callback:null,next:null}}function Nn(e,n,a){var l=e.updateQueue;if(l===null)return null;if(l=l.shared,(_e&2)!==0){var c=l.pending;return c===null?n.next=n:(n.next=c.next,c.next=n),l.pending=n,cn(e,a)}return c=l.interleaved,c===null?(n.next=n,Us(l)):(n.next=c.next,c.next=n),l.interleaved=n,cn(e,a)}function Li(e,n,a){if(n=n.updateQueue,n!==null&&(n=n.shared,(a&4194240)!==0)){var l=n.lanes;l&=e.pendingLanes,a|=l,n.lanes=a,rs(e,a)}}function Ed(e,n){var a=e.updateQueue,l=e.alternate;if(l!==null&&(l=l.updateQueue,a===l)){var c=null,f=null;if(a=a.firstBaseUpdate,a!==null){do{var v={eventTime:a.eventTime,lane:a.lane,tag:a.tag,payload:a.payload,callback:a.callback,next:null};f===null?c=f=v:f=f.next=v,a=a.next}while(a!==null);f===null?c=f=n:f=f.next=n}else c=f=n;a={baseState:l.baseState,firstBaseUpdate:c,lastBaseUpdate:f,shared:l.shared,effects:l.effects},e.updateQueue=a;return}e=a.lastBaseUpdate,e===null?a.firstBaseUpdate=n:e.next=n,a.lastBaseUpdate=n}function Di(e,n,a,l){var c=e.updateQueue;Pn=!1;var f=c.firstBaseUpdate,v=c.lastBaseUpdate,_=c.shared.pending;if(_!==null){c.shared.pending=null;var I=_,R=I.next;I.next=null,v===null?f=R:v.next=R,v=I;var U=e.alternate;U!==null&&(U=U.updateQueue,_=U.lastBaseUpdate,_!==v&&(_===null?U.firstBaseUpdate=R:_.next=R,U.lastBaseUpdate=I))}if(f!==null){var q=c.baseState;v=0,U=R=I=null,_=f;do{var M=_.lane,K=_.eventTime;if((l&M)===M){U!==null&&(U=U.next={eventTime:K,lane:0,tag:_.tag,payload:_.payload,callback:_.callback,next:null});e:{var te=e,ne=_;switch(M=n,K=a,ne.tag){case 1:if(te=ne.payload,typeof te=="function"){q=te.call(K,q,M);break e}q=te;break e;case 3:te.flags=te.flags&-65537|128;case 0:if(te=ne.payload,M=typeof te=="function"?te.call(K,q,M):te,M==null)break e;q=X({},q,M);break e;case 2:Pn=!0}}_.callback!==null&&_.lane!==0&&(e.flags|=64,M=c.effects,M===null?c.effects=[_]:M.push(_))}else K={eventTime:K,lane:M,tag:_.tag,payload:_.payload,callback:_.callback,next:null},U===null?(R=U=K,I=q):U=U.next=K,v|=M;if(_=_.next,_===null){if(_=c.shared.pending,_===null)break;M=_,_=M.next,M.next=null,c.lastBaseUpdate=M,c.shared.pending=null}}while(!0);if(U===null&&(I=q),c.baseState=I,c.firstBaseUpdate=R,c.lastBaseUpdate=U,n=c.shared.interleaved,n!==null){c=n;do v|=c.lane,c=c.next;while(c!==n)}else f===null&&(c.shared.lanes=0);sr|=v,e.lanes=v,e.memoizedState=q}}function Id(e,n,a){if(e=n.effects,n.effects=null,e!==null)for(n=0;na?a:4,e(!0);var l=Gs.transition;Gs.transition={};try{e(!1),n()}finally{be=a,Gs.transition=l}}function Ud(){return $t().memoizedState}function tg(e,n,a){var l=$n(e);if(a={lane:l,action:a,hasEagerState:!1,eagerState:null,next:null},Zd(e))qd(n,a);else if(a=wd(e,n,a,l),a!==null){var c=pt();Gt(a,e,l,c),Vd(a,n,l)}}function ng(e,n,a){var l=$n(e),c={lane:l,action:a,hasEagerState:!1,eagerState:null,next:null};if(Zd(e))qd(n,c);else{var f=e.alternate;if(e.lanes===0&&(f===null||f.lanes===0)&&(f=n.lastRenderedReducer,f!==null))try{var v=n.lastRenderedState,_=f(v,a);if(c.hasEagerState=!0,c.eagerState=_,Zt(_,v)){var I=n.interleaved;I===null?(c.next=c,Us(n)):(c.next=I.next,I.next=c),n.interleaved=c;return}}catch{}a=wd(e,n,c,l),a!==null&&(c=pt(),Gt(a,e,l,c),Vd(a,n,l))}}function Zd(e){var n=e.alternate;return e===Me||n!==null&&n===Me}function qd(e,n){Oo=Ui=!0;var a=e.pending;a===null?n.next=n:(n.next=a.next,a.next=n),e.pending=n}function Vd(e,n,a){if((a&4194240)!==0){var l=n.lanes;l&=e.pendingLanes,a|=l,n.lanes=a,rs(e,a)}}var Vi={readContext:jt,useCallback:lt,useContext:lt,useEffect:lt,useImperativeHandle:lt,useInsertionEffect:lt,useLayoutEffect:lt,useMemo:lt,useReducer:lt,useRef:lt,useState:lt,useDebugValue:lt,useDeferredValue:lt,useTransition:lt,useMutableSource:lt,useSyncExternalStore:lt,useId:lt,unstable_isNewReconciler:!1},rg={readContext:jt,useCallback:function(e,n){return nn().memoizedState=[e,n===void 0?null:n],e},useContext:jt,useEffect:Ad,useImperativeHandle:function(e,n,a){return a=a!=null?a.concat([e]):null,Zi(4194308,4,$d.bind(null,n,e),a)},useLayoutEffect:function(e,n){return Zi(4194308,4,e,n)},useInsertionEffect:function(e,n){return Zi(4,2,e,n)},useMemo:function(e,n){var a=nn();return n=n===void 0?null:n,e=e(),a.memoizedState=[e,n],e},useReducer:function(e,n,a){var l=nn();return n=a!==void 0?a(n):n,l.memoizedState=l.baseState=n,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:n},l.queue=e,e=e.dispatch=tg.bind(null,Me,e),[l.memoizedState,e]},useRef:function(e){var n=nn();return e={current:e},n.memoizedState=e},useState:Pd,useDebugValue:tl,useDeferredValue:function(e){return nn().memoizedState=e},useTransition:function(){var e=Pd(!1),n=e[0];return e=eg.bind(null,e[1]),nn().memoizedState=e,[n,e]},useMutableSource:function(){},useSyncExternalStore:function(e,n,a){var l=Me,c=nn();if(Oe){if(a===void 0)throw Error(i(407));a=a()}else{if(a=n(),nt===null)throw Error(i(349));(ar&30)!==0||zd(l,n,a)}c.memoizedState=a;var f={value:a,getSnapshot:n};return c.queue=f,Ad(Td.bind(null,l,f,e),[e]),l.flags|=2048,Lo(9,Cd.bind(null,l,f,a,n),void 0,null),a},useId:function(){var e=nn(),n=nt.identifierPrefix;if(Oe){var a=un,l=ln;a=(l&~(1<<32-Ut(l)-1)).toString(32)+a,n=":"+n+"R"+a,a=jo++,0<\/script>",e=e.removeChild(e.firstChild)):typeof l.is=="string"?e=v.createElement(a,{is:l.is}):(e=v.createElement(a),a==="select"&&(v=e,l.multiple?v.multiple=!0:l.size&&(v.size=l.size))):e=v.createElementNS(e,a),e[en]=n,e[To]=l,dp(e,n,!1,!1),n.stateNode=e;e:{switch(v=Ha(a,l),a){case"dialog":Pe("cancel",e),Pe("close",e),c=l;break;case"iframe":case"object":case"embed":Pe("load",e),c=l;break;case"video":case"audio":for(c=0;cUr&&(n.flags|=128,l=!0,Do(f,!1),n.lanes=4194304)}else{if(!l)if(e=Mi(v),e!==null){if(n.flags|=128,l=!0,a=e.updateQueue,a!==null&&(n.updateQueue=a,n.flags|=4),Do(f,!0),f.tail===null&&f.tailMode==="hidden"&&!v.alternate&&!Oe)return ut(n),null}else 2*We()-f.renderingStartTime>Ur&&a!==1073741824&&(n.flags|=128,l=!0,Do(f,!1),n.lanes=4194304);f.isBackwards?(v.sibling=n.child,n.child=v):(a=f.last,a!==null?a.sibling=v:n.child=v,f.last=v)}return f.tail!==null?(n=f.tail,f.rendering=n,f.tail=n.sibling,f.renderingStartTime=We(),n.sibling=null,a=De.current,Te(De,l?a&1|2:a&1),n):(ut(n),null);case 22:case 23:return kl(),l=n.memoizedState!==null,e!==null&&e.memoizedState!==null!==l&&(n.flags|=8192),l&&(n.mode&1)!==0?(Bt&1073741824)!==0&&(ut(n),n.subtreeFlags&6&&(n.flags|=8192)):ut(n),null;case 24:return null;case 25:return null}throw Error(i(156,n.tag))}function dg(e,n){switch(As(n),n.tag){case 1:return gt(n.type)&&Ti(),e=n.flags,e&65536?(n.flags=e&-65537|128,n):null;case 3:return Lr(),Ne(ht),Ne(st),Hs(),e=n.flags,(e&65536)!==0&&(e&128)===0?(n.flags=e&-65537|128,n):null;case 5:return Vs(n),null;case 13:if(Ne(De),e=n.memoizedState,e!==null&&e.dehydrated!==null){if(n.alternate===null)throw Error(i(340));Ar()}return e=n.flags,e&65536?(n.flags=e&-65537|128,n):null;case 19:return Ne(De),null;case 4:return Lr(),null;case 10:return Ms(n.type._context),null;case 22:case 23:return kl(),null;case 24:return null;default:return null}}var Ji=!1,ct=!1,pg=typeof WeakSet=="function"?WeakSet:Set,Y=null;function Mr(e,n){var a=e.ref;if(a!==null)if(typeof a=="function")try{a(null)}catch(l){Ve(e,n,l)}else a.current=null}function fl(e,n,a){try{a()}catch(l){Ve(e,n,l)}}var mp=!1;function fg(e,n){if(ks=vi,e=Wc(),gs(e)){if("selectionStart"in e)var a={start:e.selectionStart,end:e.selectionEnd};else e:{a=(a=e.ownerDocument)&&a.defaultView||window;var l=a.getSelection&&a.getSelection();if(l&&l.rangeCount!==0){a=l.anchorNode;var c=l.anchorOffset,f=l.focusNode;l=l.focusOffset;try{a.nodeType,f.nodeType}catch{a=null;break e}var v=0,_=-1,I=-1,R=0,U=0,q=e,M=null;t:for(;;){for(var K;q!==a||c!==0&&q.nodeType!==3||(_=v+c),q!==f||l!==0&&q.nodeType!==3||(I=v+l),q.nodeType===3&&(v+=q.nodeValue.length),(K=q.firstChild)!==null;)M=q,q=K;for(;;){if(q===e)break t;if(M===a&&++R===c&&(_=v),M===f&&++U===l&&(I=v),(K=q.nextSibling)!==null)break;q=M,M=q.parentNode}q=K}a=_===-1||I===-1?null:{start:_,end:I}}else a=null}a=a||{start:0,end:0}}else a=null;for(bs={focusedElem:e,selectionRange:a},vi=!1,Y=n;Y!==null;)if(n=Y,e=n.child,(n.subtreeFlags&1028)!==0&&e!==null)e.return=n,Y=e;else for(;Y!==null;){n=Y;try{var te=n.alternate;if((n.flags&1024)!==0)switch(n.tag){case 0:case 11:case 15:break;case 1:if(te!==null){var ne=te.memoizedProps,He=te.memoizedState,C=n.stateNode,k=C.getSnapshotBeforeUpdate(n.elementType===n.type?ne:Vt(n.type,ne),He);C.__reactInternalSnapshotBeforeUpdate=k}break;case 3:var B=n.stateNode.containerInfo;B.nodeType===1?B.textContent="":B.nodeType===9&&B.documentElement&&B.removeChild(B.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(i(163))}}catch(V){Ve(n,n.return,V)}if(e=n.sibling,e!==null){e.return=n.return,Y=e;break}Y=n.return}return te=mp,mp=!1,te}function Mo(e,n,a){var l=n.updateQueue;if(l=l!==null?l.lastEffect:null,l!==null){var c=l=l.next;do{if((c.tag&e)===e){var f=c.destroy;c.destroy=void 0,f!==void 0&&fl(n,a,f)}c=c.next}while(c!==l)}}function Ki(e,n){if(n=n.updateQueue,n=n!==null?n.lastEffect:null,n!==null){var a=n=n.next;do{if((a.tag&e)===e){var l=a.create;a.destroy=l()}a=a.next}while(a!==n)}}function ml(e){var n=e.ref;if(n!==null){var a=e.stateNode;e.tag,e=a,typeof n=="function"?n(e):n.current=e}}function vp(e){var n=e.alternate;n!==null&&(e.alternate=null,vp(n)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(n=e.stateNode,n!==null&&(delete n[en],delete n[To],delete n[Bs],delete n[Jh],delete n[Kh])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function hp(e){return e.tag===5||e.tag===3||e.tag===4}function gp(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||hp(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function vl(e,n,a){var l=e.tag;if(l===5||l===6)e=e.stateNode,n?a.nodeType===8?a.parentNode.insertBefore(e,n):a.insertBefore(e,n):(a.nodeType===8?(n=a.parentNode,n.insertBefore(e,a)):(n=a,n.appendChild(e)),a=a._reactRootContainer,a!=null||n.onclick!==null||(n.onclick=zi));else if(l!==4&&(e=e.child,e!==null))for(vl(e,n,a),e=e.sibling;e!==null;)vl(e,n,a),e=e.sibling}function hl(e,n,a){var l=e.tag;if(l===5||l===6)e=e.stateNode,n?a.insertBefore(e,n):a.appendChild(e);else if(l!==4&&(e=e.child,e!==null))for(hl(e,n,a),e=e.sibling;e!==null;)hl(e,n,a),e=e.sibling}var it=null,Wt=!1;function An(e,n,a){for(a=a.child;a!==null;)yp(e,n,a),a=a.sibling}function yp(e,n,a){if(Xt&&typeof Xt.onCommitFiberUnmount=="function")try{Xt.onCommitFiberUnmount(ui,a)}catch{}switch(a.tag){case 5:ct||Mr(a,n);case 6:var l=it,c=Wt;it=null,An(e,n,a),it=l,Wt=c,it!==null&&(Wt?(e=it,a=a.stateNode,e.nodeType===8?e.parentNode.removeChild(a):e.removeChild(a)):it.removeChild(a.stateNode));break;case 18:it!==null&&(Wt?(e=it,a=a.stateNode,e.nodeType===8?Ts(e.parentNode,a):e.nodeType===1&&Ts(e,a),yo(e)):Ts(it,a.stateNode));break;case 4:l=it,c=Wt,it=a.stateNode.containerInfo,Wt=!0,An(e,n,a),it=l,Wt=c;break;case 0:case 11:case 14:case 15:if(!ct&&(l=a.updateQueue,l!==null&&(l=l.lastEffect,l!==null))){c=l=l.next;do{var f=c,v=f.destroy;f=f.tag,v!==void 0&&((f&2)!==0||(f&4)!==0)&&fl(a,n,v),c=c.next}while(c!==l)}An(e,n,a);break;case 1:if(!ct&&(Mr(a,n),l=a.stateNode,typeof l.componentWillUnmount=="function"))try{l.props=a.memoizedProps,l.state=a.memoizedState,l.componentWillUnmount()}catch(_){Ve(a,n,_)}An(e,n,a);break;case 21:An(e,n,a);break;case 22:a.mode&1?(ct=(l=ct)||a.memoizedState!==null,An(e,n,a),ct=l):An(e,n,a);break;default:An(e,n,a)}}function _p(e){var n=e.updateQueue;if(n!==null){e.updateQueue=null;var a=e.stateNode;a===null&&(a=e.stateNode=new pg),n.forEach(function(l){var c=Eg.bind(null,e,l);a.has(l)||(a.add(l),l.then(c,c))})}}function Ht(e,n){var a=n.deletions;if(a!==null)for(var l=0;lc&&(c=v),l&=~f}if(l=c,l=We()-l,l=(120>l?120:480>l?480:1080>l?1080:1920>l?1920:3e3>l?3e3:4320>l?4320:1960*vg(l/1960))-l,10e?16:e,jn===null)var l=!1;else{if(e=jn,jn=null,ta=0,(_e&6)!==0)throw Error(i(331));var c=_e;for(_e|=4,Y=e.current;Y!==null;){var f=Y,v=f.child;if((Y.flags&16)!==0){var _=f.deletions;if(_!==null){for(var I=0;I<_.length;I++){var R=_[I];for(Y=R;Y!==null;){var U=Y;switch(U.tag){case 0:case 11:case 15:Mo(8,U,f)}var q=U.child;if(q!==null)q.return=U,Y=q;else for(;Y!==null;){U=Y;var M=U.sibling,K=U.return;if(vp(U),U===R){Y=null;break}if(M!==null){M.return=K,Y=M;break}Y=K}}}var te=f.alternate;if(te!==null){var ne=te.child;if(ne!==null){te.child=null;do{var He=ne.sibling;ne.sibling=null,ne=He}while(ne!==null)}}Y=f}}if((f.subtreeFlags&2064)!==0&&v!==null)v.return=f,Y=v;else e:for(;Y!==null;){if(f=Y,(f.flags&2048)!==0)switch(f.tag){case 0:case 11:case 15:Mo(9,f,f.return)}var C=f.sibling;if(C!==null){C.return=f.return,Y=C;break e}Y=f.return}}var k=e.current;for(Y=k;Y!==null;){v=Y;var B=v.child;if((v.subtreeFlags&2064)!==0&&B!==null)B.return=v,Y=B;else e:for(v=k;Y!==null;){if(_=Y,(_.flags&2048)!==0)try{switch(_.tag){case 0:case 11:case 15:Ki(9,_)}}catch(re){Ve(_,_.return,re)}if(_===v){Y=null;break e}var V=_.sibling;if(V!==null){V.return=_.return,Y=V;break e}Y=_.return}}if(_e=c,Rn(),Xt&&typeof Xt.onPostCommitFiberRoot=="function")try{Xt.onPostCommitFiberRoot(ui,e)}catch{}l=!0}return l}finally{be=a,Lt.transition=n}}return!1}function Rp(e,n,a){n=Dr(a,n),n=Jd(e,n,1),e=Nn(e,n,1),n=pt(),e!==null&&(fo(e,1,n),wt(e,n))}function Ve(e,n,a){if(e.tag===3)Rp(e,e,a);else for(;n!==null;){if(n.tag===3){Rp(n,e,a);break}else if(n.tag===1){var l=n.stateNode;if(typeof n.type.getDerivedStateFromError=="function"||typeof l.componentDidCatch=="function"&&(On===null||!On.has(l))){e=Dr(a,e),e=Kd(n,e,1),n=Nn(n,e,1),e=pt(),n!==null&&(fo(n,1,e),wt(n,e));break}}n=n.return}}function wg(e,n,a){var l=e.pingCache;l!==null&&l.delete(n),n=pt(),e.pingedLanes|=e.suspendedLanes&a,nt===e&&(at&a)===a&&(Xe===4||Xe===3&&(at&130023424)===at&&500>We()-_l?ur(e,0):yl|=a),wt(e,n)}function Pp(e,n){n===0&&((e.mode&1)===0?n=1:(n=di,di<<=1,(di&130023424)===0&&(di=4194304)));var a=pt();e=cn(e,n),e!==null&&(fo(e,n,a),wt(e,a))}function xg(e){var n=e.memoizedState,a=0;n!==null&&(a=n.retryLane),Pp(e,a)}function Eg(e,n){var a=0;switch(e.tag){case 13:var l=e.stateNode,c=e.memoizedState;c!==null&&(a=c.retryLane);break;case 19:l=e.stateNode;break;default:throw Error(i(314))}l!==null&&l.delete(n),Pp(e,a)}var Np;Np=function(e,n,a){if(e!==null)if(e.memoizedProps!==n.pendingProps||ht.current)yt=!0;else{if((e.lanes&a)===0&&(n.flags&128)===0)return yt=!1,ug(e,n,a);yt=(e.flags&131072)!==0}else yt=!1,Oe&&(n.flags&1048576)!==0&&pd(n,Ni,n.index);switch(n.lanes=0,n.tag){case 2:var l=n.type;Gi(e,n),e=n.pendingProps;var c=Rr(n,st.current);$r(n,a),c=Ks(null,n,l,e,c,a);var f=Qs();return n.flags|=1,typeof c=="object"&&c!==null&&typeof c.render=="function"&&c.$$typeof===void 0?(n.tag=1,n.memoizedState=null,n.updateQueue=null,gt(l)?(f=!0,Bi(n)):f=!1,n.memoizedState=c.state!==null&&c.state!==void 0?c.state:null,Zs(n),c.updater=Wi,n.stateNode=c,c._reactInternals=n,rl(n,l,e,a),n=sl(null,n,l,!0,f,a)):(n.tag=0,Oe&&f&&Ns(n),dt(null,n,c,a),n=n.child),n;case 16:l=n.elementType;e:{switch(Gi(e,n),e=n.pendingProps,c=l._init,l=c(l._payload),n.type=l,c=n.tag=Sg(l),e=Vt(l,e),c){case 0:n=al(null,n,l,e,a);break e;case 1:n=ip(null,n,l,e,a);break e;case 11:n=ep(null,n,l,e,a);break e;case 14:n=tp(null,n,l,Vt(l.type,e),a);break e}throw Error(i(306,l,""))}return n;case 0:return l=n.type,c=n.pendingProps,c=n.elementType===l?c:Vt(l,c),al(e,n,l,c,a);case 1:return l=n.type,c=n.pendingProps,c=n.elementType===l?c:Vt(l,c),ip(e,n,l,c,a);case 3:e:{if(ap(n),e===null)throw Error(i(387));l=n.pendingProps,f=n.memoizedState,c=f.element,xd(e,n),Di(n,l,null,a);var v=n.memoizedState;if(l=v.element,f.isDehydrated)if(f={element:l,isDehydrated:!1,cache:v.cache,pendingSuspenseBoundaries:v.pendingSuspenseBoundaries,transitions:v.transitions},n.updateQueue.baseState=f,n.memoizedState=f,n.flags&256){c=Dr(Error(i(423)),n),n=sp(e,n,l,a,c);break e}else if(l!==c){c=Dr(Error(i(424)),n),n=sp(e,n,l,a,c);break e}else for(Tt=Cn(n.stateNode.containerInfo.firstChild),Ct=n,Oe=!0,qt=null,a=_d(n,null,l,a),n.child=a;a;)a.flags=a.flags&-3|4096,a=a.sibling;else{if(Ar(),l===c){n=pn(e,n,a);break e}dt(e,n,l,a)}n=n.child}return n;case 5:return Sd(n),e===null&&js(n),l=n.type,c=n.pendingProps,f=e!==null?e.memoizedProps:null,v=c.children,zs(l,c)?v=null:f!==null&&zs(l,f)&&(n.flags|=32),op(e,n),dt(e,n,v,a),n.child;case 6:return e===null&&js(n),null;case 13:return lp(e,n,a);case 4:return qs(n,n.stateNode.containerInfo),l=n.pendingProps,e===null?n.child=Or(n,null,l,a):dt(e,n,l,a),n.child;case 11:return l=n.type,c=n.pendingProps,c=n.elementType===l?c:Vt(l,c),ep(e,n,l,c,a);case 7:return dt(e,n,n.pendingProps,a),n.child;case 8:return dt(e,n,n.pendingProps.children,a),n.child;case 12:return dt(e,n,n.pendingProps.children,a),n.child;case 10:e:{if(l=n.type._context,c=n.pendingProps,f=n.memoizedProps,v=c.value,Te(ji,l._currentValue),l._currentValue=v,f!==null)if(Zt(f.value,v)){if(f.children===c.children&&!ht.current){n=pn(e,n,a);break e}}else for(f=n.child,f!==null&&(f.return=n);f!==null;){var _=f.dependencies;if(_!==null){v=f.child;for(var I=_.firstContext;I!==null;){if(I.context===l){if(f.tag===1){I=dn(-1,a&-a),I.tag=2;var R=f.updateQueue;if(R!==null){R=R.shared;var U=R.pending;U===null?I.next=I:(I.next=U.next,U.next=I),R.pending=I}}f.lanes|=a,I=f.alternate,I!==null&&(I.lanes|=a),Fs(f.return,a,n),_.lanes|=a;break}I=I.next}}else if(f.tag===10)v=f.type===n.type?null:f.child;else if(f.tag===18){if(v=f.return,v===null)throw Error(i(341));v.lanes|=a,_=v.alternate,_!==null&&(_.lanes|=a),Fs(v,a,n),v=f.sibling}else v=f.child;if(v!==null)v.return=f;else for(v=f;v!==null;){if(v===n){v=null;break}if(f=v.sibling,f!==null){f.return=v.return,v=f;break}v=v.return}f=v}dt(e,n,c.children,a),n=n.child}return n;case 9:return c=n.type,l=n.pendingProps.children,$r(n,a),c=jt(c),l=l(c),n.flags|=1,dt(e,n,l,a),n.child;case 14:return l=n.type,c=Vt(l,n.pendingProps),c=Vt(l.type,c),tp(e,n,l,c,a);case 15:return np(e,n,n.type,n.pendingProps,a);case 17:return l=n.type,c=n.pendingProps,c=n.elementType===l?c:Vt(l,c),Gi(e,n),n.tag=1,gt(l)?(e=!0,Bi(n)):e=!1,$r(n,a),Hd(n,l,c),rl(n,l,c,a),sl(null,n,l,!0,e,a);case 19:return cp(e,n,a);case 22:return rp(e,n,a)}throw Error(i(156,n.tag))};function Ap(e,n){return fc(e,n)}function Ig(e,n,a,l){this.tag=e,this.key=a,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=n,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=l,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Dt(e,n,a,l){return new Ig(e,n,a,l)}function zl(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Sg(e){if(typeof e=="function")return zl(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Ke)return 11;if(e===kt)return 14}return 2}function Dn(e,n){var a=e.alternate;return a===null?(a=Dt(e.tag,n,e.key,e.mode),a.elementType=e.elementType,a.type=e.type,a.stateNode=e.stateNode,a.alternate=e,e.alternate=a):(a.pendingProps=n,a.type=e.type,a.flags=0,a.subtreeFlags=0,a.deletions=null),a.flags=e.flags&14680064,a.childLanes=e.childLanes,a.lanes=e.lanes,a.child=e.child,a.memoizedProps=e.memoizedProps,a.memoizedState=e.memoizedState,a.updateQueue=e.updateQueue,n=e.dependencies,a.dependencies=n===null?null:{lanes:n.lanes,firstContext:n.firstContext},a.sibling=e.sibling,a.index=e.index,a.ref=e.ref,a}function ia(e,n,a,l,c,f){var v=2;if(l=e,typeof e=="function")zl(e)&&(v=1);else if(typeof e=="string")v=5;else e:switch(e){case de:return dr(a.children,c,f,n);case pe:v=8,c|=8;break;case Re:return e=Dt(12,a,n,c|2),e.elementType=Re,e.lanes=f,e;case et:return e=Dt(13,a,n,c),e.elementType=et,e.lanes=f,e;case Qe:return e=Dt(19,a,n,c),e.elementType=Qe,e.lanes=f,e;case qe:return aa(a,c,f,n);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case ye:v=10;break e;case Ze:v=9;break e;case Ke:v=11;break e;case kt:v=14;break e;case vt:v=16,l=null;break e}throw Error(i(130,e==null?e:typeof e,""))}return n=Dt(v,a,n,c),n.elementType=e,n.type=l,n.lanes=f,n}function dr(e,n,a,l){return e=Dt(7,e,l,n),e.lanes=a,e}function aa(e,n,a,l){return e=Dt(22,e,l,n),e.elementType=qe,e.lanes=a,e.stateNode={isHidden:!1},e}function Cl(e,n,a){return e=Dt(6,e,null,n),e.lanes=a,e}function Tl(e,n,a){return n=Dt(4,e.children!==null?e.children:[],e.key,n),n.lanes=a,n.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},n}function kg(e,n,a,l,c){this.tag=n,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=ns(0),this.expirationTimes=ns(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=ns(0),this.identifierPrefix=l,this.onRecoverableError=c,this.mutableSourceEagerHydrationData=null}function Bl(e,n,a,l,c,f,v,_,I){return e=new kg(e,n,a,_,I),n===1?(n=1,f===!0&&(n|=8)):n=0,f=Dt(3,null,null,n),e.current=f,f.stateNode=e,f.memoizedState={element:l,isDehydrated:a,cache:null,transitions:null,pendingSuspenseBoundaries:null},Zs(f),e}function bg(e,n,a){var l=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(r){console.error(r)}}return t(),jl.exports=Dg(),jl.exports}var Gp;function Mg(){if(Gp)return fa;Gp=1;var t=Wf();return fa.createRoot=t.createRoot,fa.hydrateRoot=t.hydrateRoot,fa}var Fg=Mg();const Ug=qf(Fg);Wf();function Go(){return Go=Object.assign?Object.assign.bind():function(t){for(var r=1;r"u")throw new Error(r)}function au(t,r){if(!t){typeof console<"u"&&console.warn(r);try{throw new Error(r)}catch{}}}function qg(){return Math.random().toString(36).substr(2,8)}function Kp(t,r){return{usr:t.state,key:t.key,idx:r}}function ql(t,r,i,s){return i===void 0&&(i=null),Go({pathname:typeof t=="string"?t:t.pathname,search:"",hash:""},typeof r=="string"?to(r):r,{state:i,key:r&&r.key||s||qg()})}function wa(t){let{pathname:r="/",search:i="",hash:s=""}=t;return i&&i!=="?"&&(r+=i.charAt(0)==="?"?i:"?"+i),s&&s!=="#"&&(r+=s.charAt(0)==="#"?s:"#"+s),r}function to(t){let r={};if(t){let i=t.indexOf("#");i>=0&&(r.hash=t.substr(i),t=t.substr(0,i));let s=t.indexOf("?");s>=0&&(r.search=t.substr(s),t=t.substr(0,s)),t&&(r.pathname=t)}return r}function Vg(t,r,i,s){s===void 0&&(s={});let{window:u=document.defaultView,v5Compat:p=!1}=s,d=u.history,m=Zn.Pop,g=null,y=E();y==null&&(y=0,d.replaceState(Go({},d.state,{idx:y}),""));function E(){return(d.state||{idx:null}).idx}function S(){m=Zn.Pop;let O=E(),H=O==null?null:O-y;y=O,g&&g({action:m,location:W.location,delta:H})}function T(O,H){m=Zn.Push;let oe=ql(W.location,O,H);y=E()+1;let Q=Kp(oe,y),G=W.createHref(oe);try{d.pushState(Q,"",G)}catch(ee){if(ee instanceof DOMException&&ee.name==="DataCloneError")throw ee;u.location.assign(G)}p&&g&&g({action:m,location:W.location,delta:1})}function A(O,H){m=Zn.Replace;let oe=ql(W.location,O,H);y=E();let Q=Kp(oe,y),G=W.createHref(oe);d.replaceState(Q,"",G),p&&g&&g({action:m,location:W.location,delta:0})}function D(O){let H=u.location.origin!=="null"?u.location.origin:u.location.href,oe=typeof O=="string"?O:wa(O);return oe=oe.replace(/ $/,"%20"),Fe(H,"No window.location.(origin|href) available to create URL for href: "+oe),new URL(oe,H)}let W={get action(){return m},get location(){return t(u,d)},listen(O){if(g)throw new Error("A history only accepts one active listener");return u.addEventListener(Jp,S),g=O,()=>{u.removeEventListener(Jp,S),g=null}},createHref(O){return r(u,O)},createURL:D,encodeLocation(O){let H=D(O);return{pathname:H.pathname,search:H.search,hash:H.hash}},push:T,replace:A,go(O){return d.go(O)}};return W}var Qp;(function(t){t.data="data",t.deferred="deferred",t.redirect="redirect",t.error="error"})(Qp||(Qp={}));function Wg(t,r,i){return i===void 0&&(i="/"),Hg(t,r,i)}function Hg(t,r,i,s){let u=typeof r=="string"?to(r):r,p=Qr(u.pathname||"/",i);if(p==null)return null;let d=Hf(t);Gg(d);let m=null,g=i0(p);for(let y=0;m==null&&y{let g={relativePath:m===void 0?p.path||"":m,caseSensitive:p.caseSensitive===!0,childrenIndex:d,route:p};g.relativePath.startsWith("/")&&(Fe(g.relativePath.startsWith(s),'Absolute route path "'+g.relativePath+'" nested under path '+('"'+s+'" is not valid. An absolute child route path ')+"must start with the combined path of all its parent routes."),g.relativePath=g.relativePath.slice(s.length));let y=Vn([s,g.relativePath]),E=i.concat(g);p.children&&p.children.length>0&&(Fe(p.index!==!0,"Index routes must not have child routes. Please remove "+('all child routes from route path "'+y+'".')),Hf(p.children,r,E,y)),!(p.path==null&&!p.index)&&r.push({path:y,score:t0(y,p.index),routesMeta:E})};return t.forEach((p,d)=>{var m;if(p.path===""||!((m=p.path)!=null&&m.includes("?")))u(p,d);else for(let g of Gf(p.path))u(p,d,g)}),r}function Gf(t){let r=t.split("/");if(r.length===0)return[];let[i,...s]=r,u=i.endsWith("?"),p=i.replace(/\?$/,"");if(s.length===0)return u?[p,""]:[p];let d=Gf(s.join("/")),m=[];return m.push(...d.map(g=>g===""?p:[p,g].join("/"))),u&&m.push(...d),m.map(g=>t.startsWith("/")&&g===""?"/":g)}function Gg(t){t.sort((r,i)=>r.score!==i.score?i.score-r.score:n0(r.routesMeta.map(s=>s.childrenIndex),i.routesMeta.map(s=>s.childrenIndex)))}const Jg=/^:[\w-]+$/,Kg=3,Qg=2,Yg=1,Xg=10,e0=-2,Yp=t=>t==="*";function t0(t,r){let i=t.split("/"),s=i.length;return i.some(Yp)&&(s+=e0),r&&(s+=Qg),i.filter(u=>!Yp(u)).reduce((u,p)=>u+(Jg.test(p)?Kg:p===""?Yg:Xg),s)}function n0(t,r){return t.length===r.length&&t.slice(0,-1).every((s,u)=>s===r[u])?t[t.length-1]-r[r.length-1]:0}function r0(t,r,i){let{routesMeta:s}=t,u={},p="/",d=[];for(let m=0;m{let{paramName:T,isOptional:A}=E;if(T==="*"){let W=m[S]||"";d=p.slice(0,p.length-W.length).replace(/(.)\/+$/,"$1")}const D=m[S];return A&&!D?y[T]=void 0:y[T]=(D||"").replace(/%2F/g,"/"),y},{}),pathname:p,pathnameBase:d,pattern:t}}function o0(t,r,i){r===void 0&&(r=!1),i===void 0&&(i=!0),au(t==="*"||!t.endsWith("*")||t.endsWith("/*"),'Route path "'+t+'" will be treated as if it were '+('"'+t.replace(/\*$/,"/*")+'" because the `*` character must ')+"always follow a `/` in the pattern. To get rid of this warning, "+('please change the route path to "'+t.replace(/\*$/,"/*")+'".'));let s=[],u="^"+t.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(d,m,g)=>(s.push({paramName:m,isOptional:g!=null}),g?"/?([^\\/]+)?":"/([^\\/]+)"));return t.endsWith("*")?(s.push({paramName:"*"}),u+=t==="*"||t==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):i?u+="\\/*$":t!==""&&t!=="/"&&(u+="(?:(?=\\/|$))"),[new RegExp(u,r?void 0:"i"),s]}function i0(t){try{return t.split("/").map(r=>decodeURIComponent(r).replace(/\//g,"%2F")).join("/")}catch(r){return au(!1,'The URL path "'+t+'" could not be decoded because it is is a malformed URL segment. This is probably due to a bad percent '+("encoding ("+r+").")),t}}function Qr(t,r){if(r==="/")return t;if(!t.toLowerCase().startsWith(r.toLowerCase()))return null;let i=r.endsWith("/")?r.length-1:r.length,s=t.charAt(i);return s&&s!=="/"?null:t.slice(i)||"/"}const a0=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,s0=t=>a0.test(t);function l0(t,r){r===void 0&&(r="/");let{pathname:i,search:s="",hash:u=""}=typeof t=="string"?to(t):t,p;if(i)if(s0(i))p=i;else{if(i.includes("//")){let d=i;i=Jf(i),au(!1,"Pathnames cannot have embedded double slashes - normalizing "+(d+" -> "+i))}i.startsWith("/")?p=Xp(i.substring(1),"/"):p=Xp(i,r)}else p=r;return{pathname:p,search:d0(s),hash:p0(u)}}function Xp(t,r){let i=r.replace(/\/+$/,"").split("/");return t.split("/").forEach(u=>{u===".."?i.length>1&&i.pop():u!=="."&&i.push(u)}),i.length>1?i.join("/"):"/"}function Dl(t,r,i,s){return"Cannot include a '"+t+"' character in a manually specified "+("`to."+r+"` field ["+JSON.stringify(s)+"]. Please separate it out to the ")+("`to."+i+"` field. Alternatively you may provide the full path as ")+'a string in and the router will parse it for you.'}function u0(t){return t.filter((r,i)=>i===0||r.route.path&&r.route.path.length>0)}function su(t,r){let i=u0(t);return r?i.map((s,u)=>u===i.length-1?s.pathname:s.pathnameBase):i.map(s=>s.pathnameBase)}function lu(t,r,i,s){s===void 0&&(s=!1);let u;typeof t=="string"?u=to(t):(u=Go({},t),Fe(!u.pathname||!u.pathname.includes("?"),Dl("?","pathname","search",u)),Fe(!u.pathname||!u.pathname.includes("#"),Dl("#","pathname","hash",u)),Fe(!u.search||!u.search.includes("#"),Dl("#","search","hash",u)));let p=t===""||u.pathname==="",d=p?"/":u.pathname,m;if(d==null)m=i;else{let S=r.length-1;if(!s&&d.startsWith("..")){let T=d.split("/");for(;T[0]==="..";)T.shift(),S-=1;u.pathname=T.join("/")}m=S>=0?r[S]:"/"}let g=l0(u,m),y=d&&d!=="/"&&d.endsWith("/"),E=(p||d===".")&&i.endsWith("/");return!g.pathname.endsWith("/")&&(y||E)&&(g.pathname+="/"),g}const Jf=t=>t.replace(/\/\/+/g,"/"),Vn=t=>Jf(t.join("/")),c0=t=>t.replace(/\/+$/,"").replace(/^\/*/,"/"),d0=t=>!t||t==="?"?"":t.startsWith("?")?t:"?"+t,p0=t=>!t||t==="#"?"":t.startsWith("#")?t:"#"+t;function f0(t){return t!=null&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.internal=="boolean"&&"data"in t}const Kf=["post","put","patch","delete"];new Set(Kf);const m0=["get",...Kf];new Set(m0);function Jo(){return Jo=Object.assign?Object.assign.bind():function(t){for(var r=1;r{m.current=!0}),b.useCallback(function(y,E){if(E===void 0&&(E={}),!m.current)return;if(typeof y=="number"){s.go(y);return}let S=lu(y,JSON.parse(d),p,E.relative==="path");t==null&&r!=="/"&&(S.pathname=S.pathname==="/"?r:Vn([r,S.pathname])),(E.replace?s.replace:s.push)(S,E.state,E)},[r,s,d,p,t])}function A6(){let{matches:t}=b.useContext(yn),r=t[t.length-1];return r?r.params:{}}function Ta(t,r){let{relative:i}=r===void 0?{}:r,{future:s}=b.useContext(gn),{matches:u}=b.useContext(yn),{pathname:p}=_n(),d=JSON.stringify(su(u,s.v7_relativeSplatPath));return b.useMemo(()=>lu(t,JSON.parse(d),p,i==="path"),[t,d,p,i])}function g0(t,r){return y0(t,r)}function y0(t,r,i,s){no()||Fe(!1);let{navigator:u}=b.useContext(gn),{matches:p}=b.useContext(yn),d=p[p.length-1],m=d?d.params:{};d&&d.pathname;let g=d?d.pathnameBase:"/";d&&d.route;let y=_n(),E;if(r){var S;let O=typeof r=="string"?to(r):r;g==="/"||(S=O.pathname)!=null&&S.startsWith(g)||Fe(!1),E=O}else E=y;let T=E.pathname||"/",A=T;if(g!=="/"){let O=g.replace(/^\//,"").split("/");A="/"+T.replace(/^\//,"").split("/").slice(O.length).join("/")}let D=Wg(t,{pathname:A}),W=I0(D&&D.map(O=>Object.assign({},O,{params:Object.assign({},m,O.params),pathname:Vn([g,u.encodeLocation?u.encodeLocation(O.pathname).pathname:O.pathname]),pathnameBase:O.pathnameBase==="/"?g:Vn([g,u.encodeLocation?u.encodeLocation(O.pathnameBase).pathname:O.pathnameBase])})),p,i,s);return r&&W?b.createElement(Ca.Provider,{value:{location:Jo({pathname:"/",search:"",hash:"",state:null,key:"default"},E),navigationType:Zn.Pop}},W):W}function _0(){let t=z0(),r=f0(t)?t.status+" "+t.statusText:t instanceof Error?t.message:JSON.stringify(t),i=t instanceof Error?t.stack:null,u={padding:"0.5rem",backgroundColor:"rgba(200,200,200, 0.5)"};return b.createElement(b.Fragment,null,b.createElement("h2",null,"Unexpected Application Error!"),b.createElement("h3",{style:{fontStyle:"italic"}},r),i?b.createElement("pre",{style:u},i):null,null)}const w0=b.createElement(_0,null);class x0 extends b.Component{constructor(r){super(r),this.state={location:r.location,revalidation:r.revalidation,error:r.error}}static getDerivedStateFromError(r){return{error:r}}static getDerivedStateFromProps(r,i){return i.location!==r.location||i.revalidation!=="idle"&&r.revalidation==="idle"?{error:r.error,location:r.location,revalidation:r.revalidation}:{error:r.error!==void 0?r.error:i.error,location:i.location,revalidation:r.revalidation||i.revalidation}}componentDidCatch(r,i){console.error("React Router caught the following error during render",r,i)}render(){return this.state.error!==void 0?b.createElement(yn.Provider,{value:this.props.routeContext},b.createElement(Yf.Provider,{value:this.state.error,children:this.props.component})):this.props.children}}function E0(t){let{routeContext:r,match:i,children:s}=t,u=b.useContext(za);return u&&u.static&&u.staticContext&&(i.route.errorElement||i.route.ErrorBoundary)&&(u.staticContext._deepestRenderedBoundaryId=i.route.id),b.createElement(yn.Provider,{value:r},s)}function I0(t,r,i,s){var u;if(r===void 0&&(r=[]),i===void 0&&(i=null),s===void 0&&(s=null),t==null){var p;if(!i)return null;if(i.errors)t=i.matches;else if((p=s)!=null&&p.v7_partialHydration&&r.length===0&&!i.initialized&&i.matches.length>0)t=i.matches;else return null}let d=t,m=(u=i)==null?void 0:u.errors;if(m!=null){let E=d.findIndex(S=>S.route.id&&m?.[S.route.id]!==void 0);E>=0||Fe(!1),d=d.slice(0,Math.min(d.length,E+1))}let g=!1,y=-1;if(i&&s&&s.v7_partialHydration)for(let E=0;E=0?d=d.slice(0,y+1):d=[d[0]];break}}}return d.reduceRight((E,S,T)=>{let A,D=!1,W=null,O=null;i&&(A=m&&S.route.id?m[S.route.id]:void 0,W=S.route.errorElement||w0,g&&(y<0&&T===0?(T0("route-fallback"),D=!0,O=null):y===T&&(D=!0,O=S.route.hydrateFallbackElement||null)));let H=r.concat(d.slice(0,T+1)),oe=()=>{let Q;return A?Q=W:D?Q=O:S.route.Component?Q=b.createElement(S.route.Component,null):S.route.element?Q=S.route.element:Q=E,b.createElement(E0,{match:S,routeContext:{outlet:E,matches:H,isDataRoute:i!=null},children:Q})};return i&&(S.route.ErrorBoundary||S.route.errorElement||T===0)?b.createElement(x0,{location:i.location,revalidation:i.revalidation,component:W,error:A,children:oe(),routeContext:{outlet:null,matches:H,isDataRoute:!0}}):oe()},null)}var em=(function(t){return t.UseBlocker="useBlocker",t.UseRevalidator="useRevalidator",t.UseNavigateStable="useNavigate",t})(em||{}),tm=(function(t){return t.UseBlocker="useBlocker",t.UseLoaderData="useLoaderData",t.UseActionData="useActionData",t.UseRouteError="useRouteError",t.UseNavigation="useNavigation",t.UseRouteLoaderData="useRouteLoaderData",t.UseMatches="useMatches",t.UseRevalidator="useRevalidator",t.UseNavigateStable="useNavigate",t.UseRouteId="useRouteId",t})(tm||{});function S0(t){let r=b.useContext(za);return r||Fe(!1),r}function k0(t){let r=b.useContext(Qf);return r||Fe(!1),r}function b0(t){let r=b.useContext(yn);return r||Fe(!1),r}function nm(t){let r=b0(),i=r.matches[r.matches.length-1];return i.route.id||Fe(!1),i.route.id}function z0(){var t;let r=b.useContext(Yf),i=k0(),s=nm();return r!==void 0?r:(t=i.errors)==null?void 0:t[s]}function C0(){let{router:t}=S0(em.UseNavigateStable),r=nm(tm.UseNavigateStable),i=b.useRef(!1);return Xf(()=>{i.current=!0}),b.useCallback(function(u,p){p===void 0&&(p={}),i.current&&(typeof u=="number"?t.navigate(u):t.navigate(u,Jo({fromRouteId:r},p)))},[t,r])}const ef={};function T0(t,r,i){ef[t]||(ef[t]=!0)}function B0(t,r){t?.v7_startTransition,t?.v7_relativeSplatPath}function R0(t){let{to:r,replace:i,state:s,relative:u}=t;no()||Fe(!1);let{future:p,static:d}=b.useContext(gn),{matches:m}=b.useContext(yn),{pathname:g}=_n(),y=uu(),E=lu(r,su(m,p.v7_relativeSplatPath),g,u==="path"),S=JSON.stringify(E);return b.useEffect(()=>y(JSON.parse(S),{replace:i,state:s,relative:u}),[y,S,u,i,s]),null}function on(t){Fe(!1)}function P0(t){let{basename:r="/",children:i=null,location:s,navigationType:u=Zn.Pop,navigator:p,static:d=!1,future:m}=t;no()&&Fe(!1);let g=r.replace(/^\/*/,"/"),y=b.useMemo(()=>({basename:g,navigator:p,static:d,future:Jo({v7_relativeSplatPath:!1},m)}),[g,m,p,d]);typeof s=="string"&&(s=to(s));let{pathname:E="/",search:S="",hash:T="",state:A=null,key:D="default"}=s,W=b.useMemo(()=>{let O=Qr(E,g);return O==null?null:{location:{pathname:O,search:S,hash:T,state:A,key:D},navigationType:u}},[g,E,S,T,A,D,u]);return W==null?null:b.createElement(gn.Provider,{value:y},b.createElement(Ca.Provider,{children:i,value:W}))}function N0(t){let{children:r,location:i}=t;return g0(Wl(r),i)}new Promise(()=>{});function Wl(t,r){r===void 0&&(r=[]);let i=[];return b.Children.forEach(t,(s,u)=>{if(!b.isValidElement(s))return;let p=[...r,u];if(s.type===b.Fragment){i.push.apply(i,Wl(s.props.children,p));return}s.type!==on&&Fe(!1),!s.props.index||!s.props.children||Fe(!1);let d={id:s.props.id||p.join("-"),caseSensitive:s.props.caseSensitive,element:s.props.element,Component:s.props.Component,index:s.props.index,path:s.props.path,loader:s.props.loader,action:s.props.action,errorElement:s.props.errorElement,ErrorBoundary:s.props.ErrorBoundary,hasErrorBoundary:s.props.ErrorBoundary!=null||s.props.errorElement!=null,shouldRevalidate:s.props.shouldRevalidate,handle:s.props.handle,lazy:s.props.lazy};s.props.children&&(d.children=Wl(s.props.children,p)),i.push(d)}),i}function xa(){return xa=Object.assign?Object.assign.bind():function(t){for(var r=1;r{let s=t[i];return r.concat(Array.isArray(s)?s.map(u=>[i,u]):[[i,s]])},[]))}function j0(t,r){let i=Hl(t);return r&&r.forEach((s,u)=>{i.has(u)||r.getAll(u).forEach(p=>{i.append(u,p)})}),i}const $0=["onClick","relative","reloadDocument","replace","state","target","to","preventScrollReset","viewTransition"],L0=["aria-current","caseSensitive","className","end","style","to","viewTransition","children"],D0="6";try{window.__reactRouterVersion=D0}catch{}const M0=b.createContext({isTransitioning:!1}),F0="startTransition",tf=jg[F0];function U0(t){let{basename:r,children:i,future:s,window:u}=t,p=b.useRef();p.current==null&&(p.current=Zg({window:u,v5Compat:!0}));let d=p.current,[m,g]=b.useState({action:d.action,location:d.location}),{v7_startTransition:y}=s||{},E=b.useCallback(S=>{y&&tf?tf(()=>g(S)):g(S)},[g,y]);return b.useLayoutEffect(()=>d.listen(E),[d,E]),b.useEffect(()=>B0(s),[s]),b.createElement(P0,{basename:r,children:i,location:m.location,navigationType:m.action,navigator:d,future:s})}const Z0=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u",q0=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,V0=b.forwardRef(function(r,i){let{onClick:s,relative:u,reloadDocument:p,replace:d,state:m,target:g,to:y,preventScrollReset:E,viewTransition:S}=r,T=rm(r,$0),{basename:A}=b.useContext(gn),D,W=!1;if(typeof y=="string"&&q0.test(y)&&(D=y,Z0))try{let Q=new URL(window.location.href),G=y.startsWith("//")?new URL(Q.protocol+y):new URL(y),ee=Qr(G.pathname,A);G.origin===Q.origin&&ee!=null?y=ee+G.search+G.hash:W=!0}catch{}let O=v0(y,{relative:u}),H=G0(y,{replace:d,state:m,target:g,preventScrollReset:E,relative:u,viewTransition:S});function oe(Q){s&&s(Q),Q.defaultPrevented||H(Q)}return b.createElement("a",xa({},T,{href:D||O,onClick:W||p?s:oe,ref:i,target:g}))}),W0=b.forwardRef(function(r,i){let{"aria-current":s="page",caseSensitive:u=!1,className:p="",end:d=!1,style:m,to:g,viewTransition:y,children:E}=r,S=rm(r,L0),T=Ta(g,{relative:S.relative}),A=_n(),D=b.useContext(Qf),{navigator:W,basename:O}=b.useContext(gn),H=D!=null&&J0(T)&&y===!0,oe=W.encodeLocation?W.encodeLocation(T).pathname:T.pathname,Q=A.pathname,G=D&&D.navigation&&D.navigation.location?D.navigation.location.pathname:null;u||(Q=Q.toLowerCase(),G=G?G.toLowerCase():null,oe=oe.toLowerCase()),G&&O&&(G=Qr(G,O)||G);const ee=oe!=="/"&&oe.endsWith("/")?oe.length-1:oe.length;let ue=Q===oe||!d&&Q.startsWith(oe)&&Q.charAt(ee)==="/",de=G!=null&&(G===oe||!d&&G.startsWith(oe)&&G.charAt(oe.length)==="/"),pe={isActive:ue,isPending:de,isTransitioning:H},Re=ue?s:void 0,ye;typeof p=="function"?ye=p(pe):ye=[p,ue?"active":null,de?"pending":null,H?"transitioning":null].filter(Boolean).join(" ");let Ze=typeof m=="function"?m(pe):m;return b.createElement(V0,xa({},S,{"aria-current":Re,className:ye,ref:i,style:Ze,to:g,viewTransition:y}),typeof E=="function"?E(pe):E)});var Gl;(function(t){t.UseScrollRestoration="useScrollRestoration",t.UseSubmit="useSubmit",t.UseSubmitFetcher="useSubmitFetcher",t.UseFetcher="useFetcher",t.useViewTransitionState="useViewTransitionState"})(Gl||(Gl={}));var nf;(function(t){t.UseFetcher="useFetcher",t.UseFetchers="useFetchers",t.UseScrollRestoration="useScrollRestoration"})(nf||(nf={}));function H0(t){let r=b.useContext(za);return r||Fe(!1),r}function G0(t,r){let{target:i,replace:s,state:u,preventScrollReset:p,relative:d,viewTransition:m}=r===void 0?{}:r,g=uu(),y=_n(),E=Ta(t,{relative:d});return b.useCallback(S=>{if(O0(S,i)){S.preventDefault();let T=s!==void 0?s:wa(y)===wa(E);g(t,{replace:T,state:u,preventScrollReset:p,relative:d,viewTransition:m})}},[y,g,E,s,u,i,t,p,d,m])}function O6(t){let r=b.useRef(Hl(t)),i=b.useRef(!1),s=_n(),u=b.useMemo(()=>j0(s.search,i.current?null:r.current),[s.search]),p=uu(),d=b.useCallback((m,g)=>{const y=Hl(typeof m=="function"?m(u):m);i.current=!0,p("?"+y,g)},[p,u]);return[u,d]}function J0(t,r){r===void 0&&(r={});let i=b.useContext(M0);i==null&&Fe(!1);let{basename:s}=H0(Gl.useViewTransitionState),u=Ta(t,{relative:r.relative});if(!i.isTransitioning)return!1;let p=Qr(i.currentLocation.pathname,s)||i.currentLocation.pathname,d=Qr(i.nextLocation.pathname,s)||i.nextLocation.pathname;return Vl(u.pathname,d)!=null||Vl(u.pathname,p)!=null}const K0=new Set(["failed","errored","stuck","crashed"]),Q0=new Set(["rate-limited","rate_limited","waiting"]),Y0={"awaiting-input":"respond",errored:"reset","rate-limited":"nudge",stalled:"nudge"};function X0(t,r){const i=new Map;for(const u of r)i.set(u.agentName,u.prompt);const s=[];for(const u of t){const p=i.has(u.name),d=ey(u,p);d!==null&&s.push({name:u.name,reason:d,detail:ny(u,d,i.get(u.name)),action:Y0[d]})}return s}function ey(t,r){if(r)return"awaiting-input";const i=t.state.toLowerCase();return K0.has(i)?"errored":Q0.has(i)?"rate-limited":ty(t,i)?"stalled":null}function ty(t,r){return r==="detached"?!0:t.running&&t.session===void 0}function ny(t,r,i){switch(r){case"awaiting-input":return ry(i);case"errored":return`Exited ${t.state}.`;case"rate-limited":return"Throttled by a provider limit.";case"stalled":return t.state.toLowerCase()==="detached"?"Detached from its session.":"Running with no live session."}}function ry(t){if(t===void 0)return"Awaiting your decision.";const r=t.split(` -`,1)[0]?.trim()??"";return r.length>0?r:"Awaiting your decision."}function oy(t){return t.filter(r=>r.phase==="blocked").map(r=>({id:r.id,title:r.title,reason:iy(r),remedy:ay(r),scope:r.scope}))}function iy(t){const r=sy(t);if(r!==null)return`Blocked at ${r}`;const i=t.statusCounts.blocked??0;return i>0?`${i} blocked step${i===1?"":"s"}`:"Blocked, awaiting operator"}function ay(t){return t.activeAssignees.length===0?"No worker assigned. Claim or dispatch one.":"Open run detail to review the blocked step."}function sy(t){if(t.progress.status==="active_step"||t.progress.status==="stage_only"){const r=t.progress.stage;if(r.status==="available")return r.label}return null}const om=/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i,ly={bead:"bead.",session:"session."};function Wr(t){return t instanceof Error?t.message:typeof t=="string"?t:"unknown error"}function uy(t){if(!t)return"";let r=t.length;for(;r>0&&t.charCodeAt(r-1)===47;)r--;const i=t.slice(0,r);return i.slice(i.lastIndexOf("/")+1)||i}const cy="polecat";function dy(t){return uy(t).toLowerCase().includes(cy)}function py(t){return t.filter(r=>!r.read&&!dy(r.from))}const fy="modulepreload",my=function(t){return"/"+t},rf={},wn=function(r,i,s){let u=Promise.resolve();if(i&&i.length>0){let g=function(y){return Promise.all(y.map(E=>Promise.resolve(E).then(S=>({status:"fulfilled",value:S}),S=>({status:"rejected",reason:S}))))};document.getElementsByTagName("link");const d=document.querySelector("meta[property=csp-nonce]"),m=d?.nonce||d?.getAttribute("nonce");u=g(i.map(y=>{if(y=my(y),y in rf)return;rf[y]=!0;const E=y.endsWith(".css"),S=E?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${y}"]${S}`))return;const T=document.createElement("link");if(T.rel=E?"stylesheet":fy,E||(T.as="script"),T.crossOrigin="",T.href=y,m&&T.setAttribute("nonce",m),document.head.appendChild(T),E)return new Promise((A,D)=>{T.addEventListener("load",A),T.addEventListener("error",()=>D(new Error(`Unable to preload CSS for ${y}`)))})}))}function p(d){const m=new Event("vite:preloadError",{cancelable:!0});if(m.payload=d,window.dispatchEvent(m),!m.defaultPrevented)throw d}return u.then(d=>{for(const m of d||[])m.status==="rejected"&&p(m.reason);return r().catch(p)})};let Ko=null;function vy(t){if(!om.test(t))throw new Error(`invalid city name: ${t}`);Ko=t}function Ba(){return Ko}function xn(t){const r=Ko;if(r===null)throw new Error(`${t} called before an active city was resolved`);return r}function Fn(t){if(Ko===null)throw new Error(`cityPath("${t}") called before an active city was resolved`);return`/api/city/${encodeURIComponent(Ko)}${t}`}async function hy(t,r,i,s){const u={Accept:"application/json"};s!==void 0&&(u["Content-Type"]="application/json"),t!=="GET"&&(u["X-GC-Request"]="dashboard");const p={method:t,headers:u,credentials:"same-origin"};s!==void 0&&(p.body=JSON.stringify(s));const d=await fetch(r,p);if(!d.ok){const g=await d.text(),y=gy(g),E=y?.error??(g.trim()||d.statusText||`HTTP ${d.status}`);throw new im(d.status,E,y?.kind,y?.reason)}let m;try{m=await d.json()}catch(g){throw new am(r,`body must be valid JSON: ${_y(g)}`)}return i(m,r)}function gy(t){if(t.trim().length!==0)try{const r=JSON.parse(t);return yy(r)?r:void 0}catch{return}}function yy(t){if(typeof t!="object"||t===null)return!1;const r=t;return typeof r.error!="string"||r.kind!==void 0&&typeof r.kind!="string"?!1:r.reason===void 0||typeof r.reason=="string"}async function Mt(t,r,i,s){return hy(t,r,i,s)}class im extends Error{constructor(r,i,s,u){super(i),this.status=r,this.kind=s,this.reason=u,this.name="ApiClientError"}status;kind;reason}class am extends Error{constructor(r,i){super(`Invalid API response for ${r}: ${i}`),this.url=r,this.detail=i,this.name="ApiResponseDecodeError"}url;detail}function _y(t){return t instanceof Error?t.message:typeof t=="string"?t:"unknown error"}function vr(t,r){throw new am(t,r)}function wy(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)}function Ra(t,r,i){return wy(t)||vr(r,`${i} must be an object`),t}function Pt(t,r,i,s){typeof t[s]!="string"&&vr(r,`${i}.${s} must be a string`)}function sm(t,r,i,s){const u=t[s];u!==null&&typeof u!="string"&&vr(r,`${i}.${s} must be a string or null`)}function Gn(t,r,i,s){typeof t[s]!="boolean"&&vr(r,`${i}.${s} must be a boolean`)}function of(t,r,i,s){typeof t[s]!="number"&&vr(r,`${i}.${s} must be a number`)}function Nt(t,r,i,s){Array.isArray(t[s])||vr(r,`${i}.${s} must be an array`)}function Et(t,r,i,s){Ra(t[s],r,`${i}.${s}`)}function xy(t,r,i,s){const u=t[s];u!==null&&(!Array.isArray(u)||u.some(p=>typeof p!="string"))&&vr(r,`${i}.${s} must be an array of strings or null`)}function Qt(t,r){return(i,s)=>{const u=Ra(i,s,t);return r?.(u,s),u}}function lm(t,r){return Qt(t,(i,s)=>{Nt(i,s,t,"items"),r?.(i,s)})}const Ey=Qt("health",(t,r)=>{Gn(t,r,"health","ok"),Pt(t,r,"health","ts")}),Iy=lm("commits",(t,r)=>{Pt(t,r,"commits","view")}),Sy=lm("builds",(t,r)=>{sm(t,r,"builds","source"),Gn(t,r,"builds","failed_marker")}),ky=Qt("config",(t,r)=>{Pt(t,r,"config","cityName"),Pt(t,r,"config","cityRoot"),Gn(t,r,"config","useFixtures"),Gn(t,r,"config","readOnly"),Pt(t,r,"config","operatorAlias"),Pt(t,r,"config","operatorWireAlias"),Pt(t,r,"config","decisionLabel"),xy(t,r,"config","enabledModules"),sm(t,r,"config","defaultView")}),by=Qt("system health",(t,r)=>{Et(t,r,"system health","admin"),Et(t,r,"system health","host")});function Ml(t,r,i,s){Et(t,r,i,s);const u=t[s],p=`${i}.${s}`;Pt(u,r,p,"status")}const zy=Qt("local tool versions",(t,r)=>{Ml(t,r,"local tool versions","dolt"),Ml(t,r,"local tool versions","beads"),Ml(t,r,"local tool versions","gc")}),Cy=Qt("dolt trend",(t,r)=>{Gn(t,r,"dolt trend","available"),Nt(t,r,"dolt trend","samples")}),Ty=Qt("rig store health",(t,r)=>{Gn(t,r,"rig store health","available"),Nt(t,r,"rig store health","rigs")});function af(t,r){const i=Ra(t,r,"supervisor status.status");Et(i,r,"supervisor status.status","work")}const By=Qt("supervisor status",(t,r)=>{Gn(t,r,"supervisor status","available"),t.available===!0?(Pt(t,r,"supervisor status","sampledAt"),af(t.status,r)):(Pt(t,r,"supervisor status","reason"),t.status!==null&&af(t.status,r))}),Ry=Qt("run diff",(t,r)=>{Pt(t,r,"run diff","kind"),Et(t,r,"run diff","rootPath"),Et(t,r,"run diff","comparison"),Nt(t,r,"run diff","status"),Nt(t,r,"run diff","changedFiles"),Pt(t,r,"run diff","patch"),Gn(t,r,"run diff","truncated")}),Py=Qt("run summary",(t,r)=>{of(t,r,"run summary","totalActive"),of(t,r,"run summary","totalHistorical"),Nt(t,r,"run summary","lanes"),Nt(t,r,"run summary","historicalLanes"),Nt(t,r,"run summary","blockedLanes"),Nt(t,r,"run summary","recentChanges"),Et(t,r,"run summary","runCounts"),Et(t,r,"run summary","census")}),Ny=Qt("formula run detail",(t,r)=>{Pt(t,r,"formula run detail","runId"),Et(t,r,"formula run detail","formula"),Et(t,r,"formula run detail","formulaDetail"),Et(t,r,"formula run detail","executionPath"),Et(t,r,"formula run detail","snapshotEventSeq"),Et(t,r,"formula run detail","completeness");const i=Ra(t.progress,r,"formula run detail.progress");Et(i,r,"formula run detail.progress","statusCounts"),Nt(t,r,"formula run detail","stages"),Nt(t,r,"formula run detail","nodes"),Nt(t,r,"formula run detail","edges"),Nt(t,r,"formula run detail","lanes")});function Ay(t,r="request failed"){if(t instanceof im){const i={message:t.message,status:t.status};return t.kind!==void 0&&(i.kind=t.kind),i}return t instanceof Error?{message:t.message}:{message:r}}function Jt(t,r="request failed"){const i=Ay(t,r);return i.status===void 0?i.message:`${i.status} ${i.message}`}const Yr={health(){return Mt("GET","/api/health",Ey)},listCommits(t){return Mt("GET",`/api/git/commits?view=${encodeURIComponent(t)}`,Iy)},listBuilds(){return Mt("GET","/api/builds",Sy)},config(){return Mt("GET",Fn("/config"),ky)},systemHealth(){return Mt("GET","/api/health/system",by)},localToolVersions(){return Mt("GET","/api/health/local-tools",zy)},doltTrend(){return Mt("GET",Fn("/dolt-noms/trend"),Cy)},rigStoreHealth(){return Mt("GET",Fn("/rig-store-health"),Ty)},supervisorStatus(){return Mt("GET",Fn("/supervisor-status"),By)},runDiff(t,r,i){const s=Oy(i);return Mt("POST",Fn(`/runs/${encodeURIComponent(t)}/diff${s}`),Ry,r)},runSummary(){return Mt("GET",Fn("/runs/summary"),Py)},runDetail(t){return Mt("GET",Fn(`/runs/${encodeURIComponent(t)}/detail`),Ny)},runDetailStreamUrl(t){return Fn(`/runs/${encodeURIComponent(t)}/detail/stream`)}};function Oy(t){const r=new URLSearchParams;t?.scopeKind&&t.scopeRef&&(r.set("scope_kind",t.scopeKind),r.set("scope_ref",t.scopeRef));const i=r.toString();return i.length>0?`?${i}`:""}const Xo=["agents","beads","runs","mail","activity","health"],jy=5,$y=new Map(Xo.map((t,r)=>[t,r]));function Jl(t,r={}){const i=Ly(),s=[];let u=0;for(const y of t)for(const E of y.getItems()){s.push({item:E,index:u});const S=i[E.domain],T=[...S.items,E];i[E.domain]={domain:E.domain,attention:S.attention+(E.severity==="attention"?1:0),watch:S.watch+(E.severity==="watch"?1:0),unavailable:S.unavailable+(E.severity==="unavailable"?1:0),severity:E.severity==="unavailable"?S.severity:Dy(S.severity,E.severity),items:T},u+=1}const p=s.sort((y,E)=>My(y.item,E.item)||y.index-E.index).map(({item:y})=>y),d=r.topLimit??jy,m=p.slice(0,d),g=Fy(p.slice(d));return{items:p,topItems:m,overflowByDomain:g,byDomain:i}}function Ly(){const t={};for(const r of Xo)t[r]={domain:r,attention:0,watch:0,unavailable:0,severity:null,items:[]};return t}function Dy(t,r){return t==="attention"||r==="attention"?"attention":"watch"}function My(t,r){return sf(t.severity)-sf(r.severity)||ma(r.current??!0)-ma(t.current??!0)||ma(r.actionable??!1)-ma(t.actionable??!1)||lf(r.updatedAt)-lf(t.updatedAt)||uf(t.domain)-uf(r.domain)}function sf(t){switch(t){case"attention":return 0;case"watch":return 1;case"unavailable":return 2}}function ma(t){return t?1:0}function lf(t){if(t===void 0)return 0;const r=Date.parse(t);return Number.isFinite(r)?r:0}function uf(t){return $y.get(t)??Xo.length}function Fy(t){const r=[];for(const i of Xo){let s=0,u=0,p=0;for(const m of t)m.domain===i&&(m.severity==="attention"?s+=1:m.severity==="watch"?u+=1:p+=1);const d=s+u+p;d>0&&r.push({domain:i,attention:s,watch:u,unavailable:p,total:d})}return r}const Uy=Jl([]),um=b.createContext(Uy);function Zy({contributors:t,topLimit:r,children:i}){const s=b.useMemo(()=>r===void 0?Jl(t):Jl(t,{topLimit:r}),[t,r]);return $.jsx(um.Provider,{value:s,children:i})}function qy(){return b.useContext(um)}const cu=new Map;function Fl(t){return cu.get(t)?.value}function va(t){return cu.get(t)?.fetchedAt}function Vy(t,r){cu.set(t,{value:r,fetchedAt:new Date().toISOString()})}function mn(t,r,i){const s=b.useRef(r);s.current=r;const u=b.useRef(i?.refreshFetcher);u.current=i?.refreshFetcher;const p=b.useRef(i?.sseRefreshFetcher);p.current=i?.sseRefreshFetcher;const d=b.useRef(i?.onError);d.current=i?.onError;const m=b.useRef(t);m.current=t;const g=b.useRef(0),[y,E]=b.useState(()=>Fl(t)),[S,T]=b.useState(()=>Fl(t)===void 0),[A,D]=b.useState(null),[W,O]=b.useState(()=>va(t)),H=b.useCallback(async G=>{const ee=g.current+1;g.current=ee;const ue=t;T(!0),D(null);try{const de=await G(),pe=g.current===ee,Re=m.current===ue;pe&&Re?(Vy(ue,de),E(de),O(va(ue))):Re&&(E(ye=>ye===void 0?de:ye),O(ye=>ye??va(ue)??new Date().toISOString()))}catch(de){g.current===ee&&(D(de instanceof Error?de.message:"failed to load"),d.current?.(de))}finally{g.current===ee&&T(!1)}},[t]),oe=b.useCallback(()=>H(u.current??s.current),[H]),Q=b.useCallback(()=>H(p.current??u.current??s.current),[H]);return b.useEffect(()=>{const G=Fl(t);return E(G),T(G===void 0),O(va(t)),H(s.current),()=>{g.current+=1}},[t,H]),{data:y,loading:S,error:A,fetchedAt:W,refresh:oe,cheapRefresh:Q}}var Wy=async(t,r)=>{let i=typeof r=="function"?await r(t):r;if(i)return t.scheme==="bearer"?`Bearer ${i}`:t.scheme==="basic"?`Basic ${btoa(i)}`:i},Hy={bodySerializer:t=>JSON.stringify(t,(r,i)=>typeof i=="bigint"?i.toString():i)},Gy=t=>{switch(t){case"label":return".";case"matrix":return";";case"simple":return",";default:return"&"}},Jy=t=>{switch(t){case"form":return",";case"pipeDelimited":return"|";case"spaceDelimited":return"%20";default:return","}},Ky=t=>{switch(t){case"label":return".";case"matrix":return";";case"simple":return",";default:return"&"}},cm=({allowReserved:t,explode:r,name:i,style:s,value:u})=>{if(!r){let m=(t?u:u.map(g=>encodeURIComponent(g))).join(Jy(s));switch(s){case"label":return`.${m}`;case"matrix":return`;${i}=${m}`;case"simple":return m;default:return`${i}=${m}`}}let p=Gy(s),d=u.map(m=>s==="label"||s==="simple"?t?m:encodeURIComponent(m):Pa({allowReserved:t,name:i,value:m})).join(p);return s==="label"||s==="matrix"?p+d:d},Pa=({allowReserved:t,name:r,value:i})=>{if(i==null)return"";if(typeof i=="object")throw new Error("Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.");return`${r}=${t?i:encodeURIComponent(i)}`},dm=({allowReserved:t,explode:r,name:i,style:s,value:u,valueOnly:p})=>{if(u instanceof Date)return p?u.toISOString():`${i}=${u.toISOString()}`;if(s!=="deepObject"&&!r){let g=[];Object.entries(u).forEach(([E,S])=>{g=[...g,E,t?S:encodeURIComponent(S)]});let y=g.join(",");switch(s){case"form":return`${i}=${y}`;case"label":return`.${y}`;case"matrix":return`;${i}=${y}`;default:return y}}let d=Ky(s),m=Object.entries(u).map(([g,y])=>Pa({allowReserved:t,name:s==="deepObject"?`${i}[${g}]`:g,value:y})).join(d);return s==="label"||s==="matrix"?d+m:m},Qy=/\{[^{}]+\}/g,Yy=({path:t,url:r})=>{let i=r,s=r.match(Qy);if(s)for(let u of s){let p=!1,d=u.substring(1,u.length-1),m="simple";d.endsWith("*")&&(p=!0,d=d.substring(0,d.length-1)),d.startsWith(".")?(d=d.substring(1),m="label"):d.startsWith(";")&&(d=d.substring(1),m="matrix");let g=t[d];if(g==null)continue;if(Array.isArray(g)){i=i.replace(u,cm({explode:p,name:d,style:m,value:g}));continue}if(typeof g=="object"){i=i.replace(u,dm({explode:p,name:d,style:m,value:g,valueOnly:!0}));continue}if(m==="matrix"){i=i.replace(u,`;${Pa({name:d,value:g})}`);continue}let y=encodeURIComponent(m==="label"?`.${g}`:g);i=i.replace(u,y)}return i},pm=({allowReserved:t,array:r,object:i}={})=>s=>{let u=[];if(s&&typeof s=="object")for(let p in s){let d=s[p];if(d!=null)if(Array.isArray(d)){let m=cm({allowReserved:t,explode:!0,name:p,style:"form",value:d,...r});m&&u.push(m)}else if(typeof d=="object"){let m=dm({allowReserved:t,explode:!0,name:p,style:"deepObject",value:d,...i});m&&u.push(m)}else{let m=Pa({allowReserved:t,name:p,value:d});m&&u.push(m)}}return u.join("&")},Xy=t=>{if(!t)return"stream";let r=t.split(";")[0]?.trim();if(r){if(r.startsWith("application/json")||r.endsWith("+json"))return"json";if(r==="multipart/form-data")return"formData";if(["application/","audio/","image/","video/"].some(i=>r.startsWith(i)))return"blob";if(r.startsWith("text/"))return"text"}},e7=async({security:t,...r})=>{for(let i of t){let s=await Wy(i,r.auth);if(!s)continue;let u=i.name??"Authorization";switch(i.in){case"query":r.query||(r.query={}),r.query[u]=s;break;case"cookie":r.headers.append("Cookie",`${u}=${s}`);break;default:r.headers.set(u,s);break}return}},cf=t=>t7({baseUrl:t.baseUrl,path:t.path,query:t.query,querySerializer:typeof t.querySerializer=="function"?t.querySerializer:pm(t.querySerializer),url:t.url}),t7=({baseUrl:t,path:r,query:i,querySerializer:s,url:u})=>{let p=u.startsWith("/")?u:`/${u}`,d=(t??"")+p;r&&(d=Yy({path:r,url:d}));let m=i?s(i):"";return m.startsWith("?")&&(m=m.substring(1)),m&&(d+=`?${m}`),d},df=(t,r)=>{let i={...t,...r};return i.baseUrl?.endsWith("/")&&(i.baseUrl=i.baseUrl.substring(0,i.baseUrl.length-1)),i.headers=fm(t.headers,r.headers),i},fm=(...t)=>{let r=new Headers;for(let i of t){if(!i||typeof i!="object")continue;let s=i instanceof Headers?i.entries():Object.entries(i);for(let[u,p]of s)if(p===null)r.delete(u);else if(Array.isArray(p))for(let d of p)r.append(u,d);else p!==void 0&&r.set(u,typeof p=="object"?JSON.stringify(p):p)}return r},Ul=class{_fns;constructor(){this._fns=[]}clear(){this._fns=[]}getInterceptorIndex(t){return typeof t=="number"?this._fns[t]?t:-1:this._fns.indexOf(t)}exists(t){let r=this.getInterceptorIndex(t);return!!this._fns[r]}eject(t){let r=this.getInterceptorIndex(t);this._fns[r]&&(this._fns[r]=null)}update(t,r){let i=this.getInterceptorIndex(t);return this._fns[i]?(this._fns[i]=r,t):!1}use(t){return this._fns=[...this._fns,t],this._fns.length-1}},n7=()=>({error:new Ul,request:new Ul,response:new Ul}),r7=pm({allowReserved:!1,array:{explode:!0,style:"form"},object:{explode:!0,style:"deepObject"}}),o7={"Content-Type":"application/json"},mm=(t={})=>({...Hy,headers:o7,parseAs:"auto",querySerializer:r7,...t}),vm=(t={})=>{let r=df(mm(),t),i=()=>({...r}),s=d=>(r=df(r,d),i()),u=n7(),p=async d=>{let m={...r,...d,fetch:d.fetch??r.fetch??globalThis.fetch,headers:fm(r.headers,d.headers)};m.security&&await e7({...m,security:m.security}),m.body&&m.bodySerializer&&(m.body=m.bodySerializer(m.body)),(m.body===void 0||m.body==="")&&m.headers.delete("Content-Type");let g=cf(m),y={redirect:"follow",...m},E=new Request(g,y);for(let O of u.request._fns)O&&(E=await O(E,m));let S=m.fetch,T=await S(E);for(let O of u.response._fns)O&&(T=await O(T,E,m));let A={request:E,response:T};if(T.ok){if(T.status===204||T.headers.get("Content-Length")==="0")return m.responseStyle==="data"?{}:{data:{},...A};let O=(m.parseAs==="auto"?Xy(T.headers.get("Content-Type")):m.parseAs)??"json";if(O==="stream")return m.responseStyle==="data"?T.body:{data:T.body,...A};let H=await T[O]();return O==="json"&&(m.responseValidator&&await m.responseValidator(H),m.responseTransformer&&(H=await m.responseTransformer(H))),m.responseStyle==="data"?H:{data:H,...A}}let D=await T.text();try{D=JSON.parse(D)}catch{}let W=D;for(let O of u.error._fns)O&&(W=await O(D,T,E,m));if(W=W||{},m.throwOnError)throw W;return m.responseStyle==="data"?void 0:{error:W,...A}};return{buildUrl:cf,connect:d=>p({...d,method:"CONNECT"}),delete:d=>p({...d,method:"DELETE"}),get:d=>p({...d,method:"GET"}),getConfig:i,head:d=>p({...d,method:"HEAD"}),interceptors:u,options:d=>p({...d,method:"OPTIONS"}),patch:d=>p({...d,method:"PATCH"}),post:d=>p({...d,method:"POST"}),put:d=>p({...d,method:"PUT"}),request:p,setConfig:s,trace:d=>p({...d,method:"TRACE"})}};const Se=vm(mm()),i7=t=>(t?.client??Se).get({url:"/health",...t}),a7=t=>(t?.client??Se).get({url:"/v0/cities",...t}),s7=t=>(t.client??Se).get({url:"/v0/city/{cityName}/agent/{base}/prime",...t}),l7=t=>(t.client??Se).post({url:"/v0/city/{cityName}/agent/{base}/{action}",...t}),u7=t=>(t.client??Se).get({url:"/v0/city/{cityName}/agent/{dir}/{base}/prime",...t}),c7=t=>(t.client??Se).post({url:"/v0/city/{cityName}/agent/{dir}/{base}/{action}",...t}),d7=t=>(t.client??Se).get({url:"/v0/city/{cityName}/agents",...t}),p7=t=>(t.client??Se).get({url:"/v0/city/{cityName}/bead/{id}",...t}),f7=t=>(t.client??Se).patch({url:"/v0/city/{cityName}/bead/{id}",...t,headers:{"Content-Type":"application/json",...t.headers}}),m7=t=>(t.client??Se).post({url:"/v0/city/{cityName}/bead/{id}/close",...t,headers:{"Content-Type":"application/json",...t.headers}}),v7=t=>(t.client??Se).get({url:"/v0/city/{cityName}/beads",...t}),h7=t=>(t.client??Se).post({url:"/v0/city/{cityName}/beads",...t,headers:{"Content-Type":"application/json",...t.headers}}),g7=t=>(t.client??Se).get({url:"/v0/city/{cityName}/events",...t}),y7=t=>(t.client??Se).get({url:"/v0/city/{cityName}/formulas/feed",...t}),_7=t=>(t.client??Se).get({url:"/v0/city/{cityName}/formulas/{name}",...t}),w7=t=>(t.client??Se).get({url:"/v0/city/{cityName}/health",...t}),x7=t=>(t.client??Se).get({url:"/v0/city/{cityName}/mail",...t}),E7=t=>(t.client??Se).post({url:"/v0/city/{cityName}/mail",...t,headers:{"Content-Type":"application/json",...t.headers}}),I7=t=>(t.client??Se).get({url:"/v0/city/{cityName}/mail/thread/{id}",...t}),S7=t=>(t.client??Se).post({url:"/v0/city/{cityName}/mail/{id}/archive",...t}),k7=t=>(t.client??Se).post({url:"/v0/city/{cityName}/mail/{id}/mark-unread",...t}),b7=t=>(t.client??Se).post({url:"/v0/city/{cityName}/mail/{id}/read",...t}),z7=t=>(t.client??Se).post({url:"/v0/city/{cityName}/mail/{id}/reply",...t,headers:{"Content-Type":"application/json",...t.headers}}),C7=t=>(t.client??Se).get({url:"/v0/city/{cityName}/rigs",...t}),T7=t=>(t.client??Se).get({url:"/v0/city/{cityName}/session/{id}/pending",...t}),B7=t=>(t.client??Se).post({url:"/v0/city/{cityName}/session/{id}/respond",...t,headers:{"Content-Type":"application/json",...t.headers}}),R7=t=>(t.client??Se).get({url:"/v0/city/{cityName}/session/{id}/transcript",...t}),P7=t=>(t.client??Se).get({url:"/v0/city/{cityName}/sessions",...t}),N7=t=>(t.client??Se).post({url:"/v0/city/{cityName}/sling",...t,headers:{"Content-Type":"application/json",...t.headers}}),A7=t=>(t.client??Se).get({url:"/v0/city/{cityName}/status",...t}),O7=t=>(t.client??Se).get({url:"/v0/city/{cityName}/workflow/{workflow_id}",...t});var pf;function j(t,r,i){function s(m,g){if(m._zod||Object.defineProperty(m,"_zod",{value:{def:g,constr:d,traits:new Set},enumerable:!1}),m._zod.traits.has(t))return;m._zod.traits.add(t),r(m,g);const y=d.prototype,E=Object.keys(y);for(let S=0;Si?.Parent&&m instanceof i.Parent?!0:m?._zod?.traits?.has(t)}),Object.defineProperty(d,"name",{value:t}),d}class Hr extends Error{constructor(){super("Encountered Promise during synchronous parse. Use .parseAsync() instead.")}}class hm extends Error{constructor(r){super(`Encountered unidirectional transform during encode: ${r}`),this.name="ZodEncodeError"}}(pf=globalThis).__zod_globalConfig??(pf.__zod_globalConfig={});const du=globalThis.__zod_globalConfig;function vn(t){return du}function gm(t){const r=Object.values(t).filter(s=>typeof s=="number");return Object.entries(t).filter(([s,u])=>r.indexOf(+s)===-1).map(([s,u])=>u)}function Kl(t,r){return typeof r=="bigint"?r.toString():r}function Na(t){return{get value(){{const r=t();return Object.defineProperty(this,"value",{value:r}),r}}}}function pu(t){return t==null}function fu(t){const r=t.startsWith("^")?1:0,i=t.endsWith("$")?t.length-1:t.length;return t.slice(r,i)}function j7(t,r){const i=t/r,s=Math.round(i),u=Number.EPSILON*Math.max(Math.abs(i),1);return Math.abs(i-s){};function Qo(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)}const L7=Na(()=>{if(du.jitless||typeof navigator<"u"&&navigator?.userAgent?.includes("Cloudflare"))return!1;try{const t=Function;return new t(""),!0}catch{return!1}});function Xr(t){if(Qo(t)===!1)return!1;const r=t.constructor;if(r===void 0||typeof r!="function")return!0;const i=r.prototype;return!(Qo(i)===!1||Object.prototype.hasOwnProperty.call(i,"isPrototypeOf")===!1)}function _m(t){return Xr(t)?{...t}:Array.isArray(t)?[...t]:t instanceof Map?new Map(t):t instanceof Set?new Set(t):t}const D7=new Set(["string","number","symbol"]);function eo(t){return t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}function Qn(t,r,i){const s=new t._zod.constr(r??t._zod.def);return(!r||i?.parent)&&(s._zod.parent=t),s}function ie(t){const r=t;if(!r)return{};if(typeof r=="string")return{error:()=>r};if(r?.message!==void 0){if(r?.error!==void 0)throw new Error("Cannot specify both `message` and `error` params");r.error=r.message}return delete r.message,typeof r.error=="string"?{...r,error:()=>r.error}:r}function M7(t){return Object.keys(t).filter(r=>t[r]._zod.optin==="optional"&&t[r]._zod.optout==="optional")}const F7={safeint:[Number.MIN_SAFE_INTEGER,Number.MAX_SAFE_INTEGER],int32:[-2147483648,2147483647],uint32:[0,4294967295],float32:[-34028234663852886e22,34028234663852886e22],float64:[-Number.MAX_VALUE,Number.MAX_VALUE]};function U7(t,r){const i=t._zod.def,s=i.checks;if(s&&s.length>0)throw new Error(".pick() cannot be used on object schemas containing refinements");const p=Kn(t._zod.def,{get shape(){const d={};for(const m in r){if(!(m in i.shape))throw new Error(`Unrecognized key: "${m}"`);r[m]&&(d[m]=i.shape[m])}return hr(this,"shape",d),d},checks:[]});return Qn(t,p)}function Z7(t,r){const i=t._zod.def,s=i.checks;if(s&&s.length>0)throw new Error(".omit() cannot be used on object schemas containing refinements");const p=Kn(t._zod.def,{get shape(){const d={...t._zod.def.shape};for(const m in r){if(!(m in i.shape))throw new Error(`Unrecognized key: "${m}"`);r[m]&&delete d[m]}return hr(this,"shape",d),d},checks:[]});return Qn(t,p)}function q7(t,r){if(!Xr(r))throw new Error("Invalid input to extend: expected a plain object");const i=t._zod.def.checks;if(i&&i.length>0){const p=t._zod.def.shape;for(const d in r)if(Object.getOwnPropertyDescriptor(p,d)!==void 0)throw new Error("Cannot overwrite keys on object schemas containing refinements. Use `.safeExtend()` instead.")}const u=Kn(t._zod.def,{get shape(){const p={...t._zod.def.shape,...r};return hr(this,"shape",p),p}});return Qn(t,u)}function V7(t,r){if(!Xr(r))throw new Error("Invalid input to safeExtend: expected a plain object");const i=Kn(t._zod.def,{get shape(){const s={...t._zod.def.shape,...r};return hr(this,"shape",s),s}});return Qn(t,i)}function W7(t,r){if(t._zod.def.checks?.length)throw new Error(".merge() cannot be used on object schemas containing refinements. Use .safeExtend() instead.");const i=Kn(t._zod.def,{get shape(){const s={...t._zod.def.shape,...r._zod.def.shape};return hr(this,"shape",s),s},get catchall(){return r._zod.def.catchall},checks:r._zod.def.checks??[]});return Qn(t,i)}function H7(t,r,i){const u=r._zod.def.checks;if(u&&u.length>0)throw new Error(".partial() cannot be used on object schemas containing refinements");const d=Kn(r._zod.def,{get shape(){const m=r._zod.def.shape,g={...m};if(i)for(const y in i){if(!(y in m))throw new Error(`Unrecognized key: "${y}"`);i[y]&&(g[y]=t?new t({type:"optional",innerType:m[y]}):m[y])}else for(const y in m)g[y]=t?new t({type:"optional",innerType:m[y]}):m[y];return hr(this,"shape",g),g},checks:[]});return Qn(r,d)}function G7(t,r,i){const s=Kn(r._zod.def,{get shape(){const u=r._zod.def.shape,p={...u};if(i)for(const d in i){if(!(d in p))throw new Error(`Unrecognized key: "${d}"`);i[d]&&(p[d]=new t({type:"nonoptional",innerType:u[d]}))}else for(const d in u)p[d]=new t({type:"nonoptional",innerType:u[d]});return hr(this,"shape",p),p}});return Qn(r,s)}function qr(t,r=0){if(t.aborted===!0)return!0;for(let i=r;i{var s;return(s=i).path??(s.path=[]),i.path.unshift(t),i})}function ha(t){return typeof t=="string"?t:t?.message}function hn(t,r,i){const s=t.message?t.message:ha(t.inst?._zod.def?.error?.(t))??ha(r?.error?.(t))??ha(i.customError?.(t))??ha(i.localeError?.(t))??"Invalid input",{inst:u,continue:p,input:d,...m}=t;return m.path??(m.path=[]),m.message=s,r?.reportInput&&(m.input=d),m}function mu(t){return Array.isArray(t)?"array":typeof t=="string"?"string":"unknown"}function Yo(...t){const[r,i,s]=t;return typeof r=="string"?{message:r,code:"custom",input:i,inst:s}:{...r}}const wm=(t,r)=>{t.name="$ZodError",Object.defineProperty(t,"_zod",{value:t._zod,enumerable:!1}),Object.defineProperty(t,"issues",{value:r,enumerable:!1}),t.message=JSON.stringify(r,Kl,2),Object.defineProperty(t,"toString",{value:()=>t.message,enumerable:!1})},xm=j("$ZodError",wm),Em=j("$ZodError",wm,{Parent:Error});function K7(t,r=i=>i.message){const i={},s=[];for(const u of t.issues)u.path.length>0?(i[u.path[0]]=i[u.path[0]]||[],i[u.path[0]].push(r(u))):s.push(r(u));return{formErrors:s,fieldErrors:i}}function Q7(t,r=i=>i.message){const i={_errors:[]},s=(u,p=[])=>{for(const d of u.issues)if(d.code==="invalid_union"&&d.errors.length)d.errors.map(m=>s({issues:m},[...p,...d.path]));else if(d.code==="invalid_key")s({issues:d.issues},[...p,...d.path]);else if(d.code==="invalid_element")s({issues:d.issues},[...p,...d.path]);else{const m=[...p,...d.path];if(m.length===0)i._errors.push(r(d));else{let g=i,y=0;for(;y(r,i,s,u)=>{const p=s?{...s,async:!1}:{async:!1},d=r._zod.run({value:i,issues:[]},p);if(d instanceof Promise)throw new Hr;if(d.issues.length){const m=new(u?.Err??t)(d.issues.map(g=>hn(g,p,vn())));throw ym(m,u?.callee),m}return d.value},hu=t=>async(r,i,s,u)=>{const p=s?{...s,async:!0}:{async:!0};let d=r._zod.run({value:i,issues:[]},p);if(d instanceof Promise&&(d=await d),d.issues.length){const m=new(u?.Err??t)(d.issues.map(g=>hn(g,p,vn())));throw ym(m,u?.callee),m}return d.value},Aa=t=>(r,i,s)=>{const u=s?{...s,async:!1}:{async:!1},p=r._zod.run({value:i,issues:[]},u);if(p instanceof Promise)throw new Hr;return p.issues.length?{success:!1,error:new(t??xm)(p.issues.map(d=>hn(d,u,vn())))}:{success:!0,data:p.value}},Y7=Aa(Em),Oa=t=>async(r,i,s)=>{const u=s?{...s,async:!0}:{async:!0};let p=r._zod.run({value:i,issues:[]},u);return p instanceof Promise&&(p=await p),p.issues.length?{success:!1,error:new t(p.issues.map(d=>hn(d,u,vn())))}:{success:!0,data:p.value}},X7=Oa(Em),e2=t=>(r,i,s)=>{const u=s?{...s,direction:"backward"}:{direction:"backward"};return vu(t)(r,i,u)},t2=t=>(r,i,s)=>vu(t)(r,i,s),n2=t=>async(r,i,s)=>{const u=s?{...s,direction:"backward"}:{direction:"backward"};return hu(t)(r,i,u)},r2=t=>async(r,i,s)=>hu(t)(r,i,s),o2=t=>(r,i,s)=>{const u=s?{...s,direction:"backward"}:{direction:"backward"};return Aa(t)(r,i,u)},i2=t=>(r,i,s)=>Aa(t)(r,i,s),a2=t=>async(r,i,s)=>{const u=s?{...s,direction:"backward"}:{direction:"backward"};return Oa(t)(r,i,u)},s2=t=>async(r,i,s)=>Oa(t)(r,i,s),l2=/^[cC][0-9a-z]{6,}$/,u2=/^[0-9a-z]+$/,c2=/^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$/,d2=/^[0-9a-vA-V]{20}$/,p2=/^[A-Za-z0-9]{27}$/,f2=/^[a-zA-Z0-9_-]{21}$/,m2=/^P(?:(\d+W)|(?!.*W)(?=\d|T\d)(\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+([.,]\d+)?S)?)?)$/,v2=/^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/,vf=t=>t?new RegExp(`^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-${t}[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$`):/^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/,h2=/^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$/,g2="^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$";function y2(){return new RegExp(g2,"u")}const _2=/^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/,w2=/^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/,x2=/^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$/,E2=/^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/,I2=/^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$/,Im=/^[A-Za-z0-9_-]*$/,S2=/^https?$/,k2=/^\+[1-9]\d{6,14}$/,Sm="(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))",b2=new RegExp(`^${Sm}$`);function km(t){const r="(?:[01]\\d|2[0-3]):[0-5]\\d";return typeof t.precision=="number"?t.precision===-1?`${r}`:t.precision===0?`${r}:[0-5]\\d`:`${r}:[0-5]\\d\\.\\d{${t.precision}}`:`${r}(?::[0-5]\\d(?:\\.\\d+)?)?`}function z2(t){return new RegExp(`^${km(t)}$`)}function C2(t){const r=km({precision:t.precision}),i=["Z"];t.local&&i.push(""),t.offset&&i.push("([+-](?:[01]\\d|2[0-3]):[0-5]\\d)");const s=`${r}(?:${i.join("|")})`;return new RegExp(`^${Sm}T(?:${s})$`)}const T2=t=>{const r=t?`[\\s\\S]{${t?.minimum??0},${t?.maximum??""}}`:"[\\s\\S]*";return new RegExp(`^${r}$`)},B2=/^-?\d+n?$/,R2=/^-?\d+$/,bm=/^-?\d+(?:\.\d+)?$/,P2=/^(?:true|false)$/i,N2=/^[^A-Z]*$/,A2=/^[^a-z]*$/,St=j("$ZodCheck",(t,r)=>{var i;t._zod??(t._zod={}),t._zod.def=r,(i=t._zod).onattach??(i.onattach=[])}),zm={number:"number",bigint:"bigint",object:"date"},Cm=j("$ZodCheckLessThan",(t,r)=>{St.init(t,r);const i=zm[typeof r.value];t._zod.onattach.push(s=>{const u=s._zod.bag,p=(r.inclusive?u.maximum:u.exclusiveMaximum)??Number.POSITIVE_INFINITY;r.value{(r.inclusive?s.value<=r.value:s.value{St.init(t,r);const i=zm[typeof r.value];t._zod.onattach.push(s=>{const u=s._zod.bag,p=(r.inclusive?u.minimum:u.exclusiveMinimum)??Number.NEGATIVE_INFINITY;r.value>p&&(r.inclusive?u.minimum=r.value:u.exclusiveMinimum=r.value)}),t._zod.check=s=>{(r.inclusive?s.value>=r.value:s.value>r.value)||s.issues.push({origin:i,code:"too_small",minimum:typeof r.value=="object"?r.value.getTime():r.value,input:s.value,inclusive:r.inclusive,inst:t,continue:!r.abort})}}),O2=j("$ZodCheckMultipleOf",(t,r)=>{St.init(t,r),t._zod.onattach.push(i=>{var s;(s=i._zod.bag).multipleOf??(s.multipleOf=r.value)}),t._zod.check=i=>{if(typeof i.value!=typeof r.value)throw new Error("Cannot mix number and bigint in multiple_of check.");(typeof i.value=="bigint"?i.value%r.value===BigInt(0):j7(i.value,r.value)===0)||i.issues.push({origin:typeof i.value,code:"not_multiple_of",divisor:r.value,input:i.value,inst:t,continue:!r.abort})}}),j2=j("$ZodCheckNumberFormat",(t,r)=>{St.init(t,r),r.format=r.format||"float64";const i=r.format?.includes("int"),s=i?"int":"number",[u,p]=F7[r.format];t._zod.onattach.push(d=>{const m=d._zod.bag;m.format=r.format,m.minimum=u,m.maximum=p,i&&(m.pattern=R2)}),t._zod.check=d=>{const m=d.value;if(i){if(!Number.isInteger(m)){d.issues.push({expected:s,format:r.format,code:"invalid_type",continue:!1,input:m,inst:t});return}if(!Number.isSafeInteger(m)){m>0?d.issues.push({input:m,code:"too_big",maximum:Number.MAX_SAFE_INTEGER,note:"Integers must be within the safe integer range.",inst:t,origin:s,inclusive:!0,continue:!r.abort}):d.issues.push({input:m,code:"too_small",minimum:Number.MIN_SAFE_INTEGER,note:"Integers must be within the safe integer range.",inst:t,origin:s,inclusive:!0,continue:!r.abort});return}}mp&&d.issues.push({origin:"number",input:m,code:"too_big",maximum:p,inclusive:!0,inst:t,continue:!r.abort})}}),$2=j("$ZodCheckMaxLength",(t,r)=>{var i;St.init(t,r),(i=t._zod.def).when??(i.when=s=>{const u=s.value;return!pu(u)&&u.length!==void 0}),t._zod.onattach.push(s=>{const u=s._zod.bag.maximum??Number.POSITIVE_INFINITY;r.maximum{const u=s.value;if(u.length<=r.maximum)return;const d=mu(u);s.issues.push({origin:d,code:"too_big",maximum:r.maximum,inclusive:!0,input:u,inst:t,continue:!r.abort})}}),L2=j("$ZodCheckMinLength",(t,r)=>{var i;St.init(t,r),(i=t._zod.def).when??(i.when=s=>{const u=s.value;return!pu(u)&&u.length!==void 0}),t._zod.onattach.push(s=>{const u=s._zod.bag.minimum??Number.NEGATIVE_INFINITY;r.minimum>u&&(s._zod.bag.minimum=r.minimum)}),t._zod.check=s=>{const u=s.value;if(u.length>=r.minimum)return;const d=mu(u);s.issues.push({origin:d,code:"too_small",minimum:r.minimum,inclusive:!0,input:u,inst:t,continue:!r.abort})}}),D2=j("$ZodCheckLengthEquals",(t,r)=>{var i;St.init(t,r),(i=t._zod.def).when??(i.when=s=>{const u=s.value;return!pu(u)&&u.length!==void 0}),t._zod.onattach.push(s=>{const u=s._zod.bag;u.minimum=r.length,u.maximum=r.length,u.length=r.length}),t._zod.check=s=>{const u=s.value,p=u.length;if(p===r.length)return;const d=mu(u),m=p>r.length;s.issues.push({origin:d,...m?{code:"too_big",maximum:r.length}:{code:"too_small",minimum:r.length},inclusive:!0,exact:!0,input:s.value,inst:t,continue:!r.abort})}}),ja=j("$ZodCheckStringFormat",(t,r)=>{var i,s;St.init(t,r),t._zod.onattach.push(u=>{const p=u._zod.bag;p.format=r.format,r.pattern&&(p.patterns??(p.patterns=new Set),p.patterns.add(r.pattern))}),r.pattern?(i=t._zod).check??(i.check=u=>{r.pattern.lastIndex=0,!r.pattern.test(u.value)&&u.issues.push({origin:"string",code:"invalid_format",format:r.format,input:u.value,...r.pattern?{pattern:r.pattern.toString()}:{},inst:t,continue:!r.abort})}):(s=t._zod).check??(s.check=()=>{})}),M2=j("$ZodCheckRegex",(t,r)=>{ja.init(t,r),t._zod.check=i=>{r.pattern.lastIndex=0,!r.pattern.test(i.value)&&i.issues.push({origin:"string",code:"invalid_format",format:"regex",input:i.value,pattern:r.pattern.toString(),inst:t,continue:!r.abort})}}),F2=j("$ZodCheckLowerCase",(t,r)=>{r.pattern??(r.pattern=N2),ja.init(t,r)}),U2=j("$ZodCheckUpperCase",(t,r)=>{r.pattern??(r.pattern=A2),ja.init(t,r)}),Z2=j("$ZodCheckIncludes",(t,r)=>{St.init(t,r);const i=eo(r.includes),s=new RegExp(typeof r.position=="number"?`^.{${r.position}}${i}`:i);r.pattern=s,t._zod.onattach.push(u=>{const p=u._zod.bag;p.patterns??(p.patterns=new Set),p.patterns.add(s)}),t._zod.check=u=>{u.value.includes(r.includes,r.position)||u.issues.push({origin:"string",code:"invalid_format",format:"includes",includes:r.includes,input:u.value,inst:t,continue:!r.abort})}}),q2=j("$ZodCheckStartsWith",(t,r)=>{St.init(t,r);const i=new RegExp(`^${eo(r.prefix)}.*`);r.pattern??(r.pattern=i),t._zod.onattach.push(s=>{const u=s._zod.bag;u.patterns??(u.patterns=new Set),u.patterns.add(i)}),t._zod.check=s=>{s.value.startsWith(r.prefix)||s.issues.push({origin:"string",code:"invalid_format",format:"starts_with",prefix:r.prefix,input:s.value,inst:t,continue:!r.abort})}}),V2=j("$ZodCheckEndsWith",(t,r)=>{St.init(t,r);const i=new RegExp(`.*${eo(r.suffix)}$`);r.pattern??(r.pattern=i),t._zod.onattach.push(s=>{const u=s._zod.bag;u.patterns??(u.patterns=new Set),u.patterns.add(i)}),t._zod.check=s=>{s.value.endsWith(r.suffix)||s.issues.push({origin:"string",code:"invalid_format",format:"ends_with",suffix:r.suffix,input:s.value,inst:t,continue:!r.abort})}}),W2=j("$ZodCheckOverwrite",(t,r)=>{St.init(t,r),t._zod.check=i=>{i.value=r.tx(i.value)}});class H2{constructor(r=[]){this.content=[],this.indent=0,this&&(this.args=r)}indented(r){this.indent+=1,r(this),this.indent-=1}write(r){if(typeof r=="function"){r(this,{execution:"sync"}),r(this,{execution:"async"});return}const s=r.split(` -`).filter(d=>d),u=Math.min(...s.map(d=>d.length-d.trimStart().length)),p=s.map(d=>d.slice(u)).map(d=>" ".repeat(this.indent*2)+d);for(const d of p)this.content.push(d)}compile(){const r=Function,i=this?.args,u=[...(this?.content??[""]).map(p=>` ${p}`)];return new r(...i,u.join(` -`))}}const G2={major:4,minor:4,patch:3},je=j("$ZodType",(t,r)=>{var i;t??(t={}),t._zod.def=r,t._zod.bag=t._zod.bag||{},t._zod.version=G2;const s=[...t._zod.def.checks??[]];t._zod.traits.has("$ZodCheck")&&s.unshift(t);for(const u of s)for(const p of u._zod.onattach)p(t);if(s.length===0)(i=t._zod).deferred??(i.deferred=[]),t._zod.deferred?.push(()=>{t._zod.run=t._zod.parse});else{const u=(d,m,g)=>{let y=qr(d),E;for(const S of m){if(S._zod.def.when){if(J7(d)||!S._zod.def.when(d))continue}else if(y)continue;const T=d.issues.length,A=S._zod.check(d);if(A instanceof Promise&&g?.async===!1)throw new Hr;if(E||A instanceof Promise)E=(E??Promise.resolve()).then(async()=>{await A,d.issues.length!==T&&(y||(y=qr(d,T)))});else{if(d.issues.length===T)continue;y||(y=qr(d,T))}}return E?E.then(()=>d):d},p=(d,m,g)=>{if(qr(d))return d.aborted=!0,d;const y=u(m,s,g);if(y instanceof Promise){if(g.async===!1)throw new Hr;return y.then(E=>t._zod.parse(E,g))}return t._zod.parse(y,g)};t._zod.run=(d,m)=>{if(m.skipChecks)return t._zod.parse(d,m);if(m.direction==="backward"){const y=t._zod.parse({value:d.value,issues:[]},{...m,skipChecks:!0});return y instanceof Promise?y.then(E=>p(E,d,m)):p(y,d,m)}const g=t._zod.parse(d,m);if(g instanceof Promise){if(m.async===!1)throw new Hr;return g.then(y=>u(y,s,m))}return u(g,s,m)}}ze(t,"~standard",()=>({validate:u=>{try{const p=Y7(t,u);return p.success?{value:p.data}:{issues:p.error?.issues}}catch{return X7(t,u).then(d=>d.success?{value:d.data}:{issues:d.error?.issues})}},vendor:"zod",version:1}))}),gu=j("$ZodString",(t,r)=>{je.init(t,r),t._zod.pattern=[...t?._zod.bag?.patterns??[]].pop()??T2(t._zod.bag),t._zod.parse=(i,s)=>{if(r.coerce)try{i.value=String(i.value)}catch{}return typeof i.value=="string"||i.issues.push({expected:"string",code:"invalid_type",input:i.value,inst:t}),i}}),$e=j("$ZodStringFormat",(t,r)=>{ja.init(t,r),gu.init(t,r)}),J2=j("$ZodGUID",(t,r)=>{r.pattern??(r.pattern=v2),$e.init(t,r)}),K2=j("$ZodUUID",(t,r)=>{if(r.version){const s={v1:1,v2:2,v3:3,v4:4,v5:5,v6:6,v7:7,v8:8}[r.version];if(s===void 0)throw new Error(`Invalid UUID version: "${r.version}"`);r.pattern??(r.pattern=vf(s))}else r.pattern??(r.pattern=vf());$e.init(t,r)}),Q2=j("$ZodEmail",(t,r)=>{r.pattern??(r.pattern=h2),$e.init(t,r)}),Y2=j("$ZodURL",(t,r)=>{$e.init(t,r),t._zod.check=i=>{try{const s=i.value.trim();if(!r.normalize&&r.protocol?.source===S2.source&&!/^https?:\/\//i.test(s)){i.issues.push({code:"invalid_format",format:"url",note:"Invalid URL format",input:i.value,inst:t,continue:!r.abort});return}const u=new URL(s);r.hostname&&(r.hostname.lastIndex=0,r.hostname.test(u.hostname)||i.issues.push({code:"invalid_format",format:"url",note:"Invalid hostname",pattern:r.hostname.source,input:i.value,inst:t,continue:!r.abort})),r.protocol&&(r.protocol.lastIndex=0,r.protocol.test(u.protocol.endsWith(":")?u.protocol.slice(0,-1):u.protocol)||i.issues.push({code:"invalid_format",format:"url",note:"Invalid protocol",pattern:r.protocol.source,input:i.value,inst:t,continue:!r.abort})),r.normalize?i.value=u.href:i.value=s;return}catch{i.issues.push({code:"invalid_format",format:"url",input:i.value,inst:t,continue:!r.abort})}}}),X2=j("$ZodEmoji",(t,r)=>{r.pattern??(r.pattern=y2()),$e.init(t,r)}),e3=j("$ZodNanoID",(t,r)=>{r.pattern??(r.pattern=f2),$e.init(t,r)}),t3=j("$ZodCUID",(t,r)=>{r.pattern??(r.pattern=l2),$e.init(t,r)}),n3=j("$ZodCUID2",(t,r)=>{r.pattern??(r.pattern=u2),$e.init(t,r)}),r3=j("$ZodULID",(t,r)=>{r.pattern??(r.pattern=c2),$e.init(t,r)}),o3=j("$ZodXID",(t,r)=>{r.pattern??(r.pattern=d2),$e.init(t,r)}),i3=j("$ZodKSUID",(t,r)=>{r.pattern??(r.pattern=p2),$e.init(t,r)}),a3=j("$ZodISODateTime",(t,r)=>{r.pattern??(r.pattern=C2(r)),$e.init(t,r)}),s3=j("$ZodISODate",(t,r)=>{r.pattern??(r.pattern=b2),$e.init(t,r)}),l3=j("$ZodISOTime",(t,r)=>{r.pattern??(r.pattern=z2(r)),$e.init(t,r)}),u3=j("$ZodISODuration",(t,r)=>{r.pattern??(r.pattern=m2),$e.init(t,r)}),c3=j("$ZodIPv4",(t,r)=>{r.pattern??(r.pattern=_2),$e.init(t,r),t._zod.bag.format="ipv4"}),d3=j("$ZodIPv6",(t,r)=>{r.pattern??(r.pattern=w2),$e.init(t,r),t._zod.bag.format="ipv6",t._zod.check=i=>{try{new URL(`http://[${i.value}]`)}catch{i.issues.push({code:"invalid_format",format:"ipv6",input:i.value,inst:t,continue:!r.abort})}}}),p3=j("$ZodCIDRv4",(t,r)=>{r.pattern??(r.pattern=x2),$e.init(t,r)}),f3=j("$ZodCIDRv6",(t,r)=>{r.pattern??(r.pattern=E2),$e.init(t,r),t._zod.check=i=>{const s=i.value.split("/");try{if(s.length!==2)throw new Error;const[u,p]=s;if(!p)throw new Error;const d=Number(p);if(`${d}`!==p)throw new Error;if(d<0||d>128)throw new Error;new URL(`http://[${u}]`)}catch{i.issues.push({code:"invalid_format",format:"cidrv6",input:i.value,inst:t,continue:!r.abort})}}});function Bm(t){if(t==="")return!0;if(/\s/.test(t)||t.length%4!==0)return!1;try{return atob(t),!0}catch{return!1}}const m3=j("$ZodBase64",(t,r)=>{r.pattern??(r.pattern=I2),$e.init(t,r),t._zod.bag.contentEncoding="base64",t._zod.check=i=>{Bm(i.value)||i.issues.push({code:"invalid_format",format:"base64",input:i.value,inst:t,continue:!r.abort})}});function v3(t){if(!Im.test(t))return!1;const r=t.replace(/[-_]/g,s=>s==="-"?"+":"/"),i=r.padEnd(Math.ceil(r.length/4)*4,"=");return Bm(i)}const h3=j("$ZodBase64URL",(t,r)=>{r.pattern??(r.pattern=Im),$e.init(t,r),t._zod.bag.contentEncoding="base64url",t._zod.check=i=>{v3(i.value)||i.issues.push({code:"invalid_format",format:"base64url",input:i.value,inst:t,continue:!r.abort})}}),g3=j("$ZodE164",(t,r)=>{r.pattern??(r.pattern=k2),$e.init(t,r)});function y3(t,r=null){try{const i=t.split(".");if(i.length!==3)return!1;const[s]=i;if(!s)return!1;const u=JSON.parse(atob(s));return!("typ"in u&&u?.typ!=="JWT"||!u.alg||r&&(!("alg"in u)||u.alg!==r))}catch{return!1}}const _3=j("$ZodJWT",(t,r)=>{$e.init(t,r),t._zod.check=i=>{y3(i.value,r.alg)||i.issues.push({code:"invalid_format",format:"jwt",input:i.value,inst:t,continue:!r.abort})}}),Rm=j("$ZodNumber",(t,r)=>{je.init(t,r),t._zod.pattern=t._zod.bag.pattern??bm,t._zod.parse=(i,s)=>{if(r.coerce)try{i.value=Number(i.value)}catch{}const u=i.value;if(typeof u=="number"&&!Number.isNaN(u)&&Number.isFinite(u))return i;const p=typeof u=="number"?Number.isNaN(u)?"NaN":Number.isFinite(u)?void 0:"Infinity":void 0;return i.issues.push({expected:"number",code:"invalid_type",input:u,inst:t,...p?{received:p}:{}}),i}}),w3=j("$ZodNumberFormat",(t,r)=>{j2.init(t,r),Rm.init(t,r)}),x3=j("$ZodBoolean",(t,r)=>{je.init(t,r),t._zod.pattern=P2,t._zod.parse=(i,s)=>{if(r.coerce)try{i.value=!!i.value}catch{}const u=i.value;return typeof u=="boolean"||i.issues.push({expected:"boolean",code:"invalid_type",input:u,inst:t}),i}}),E3=j("$ZodBigInt",(t,r)=>{je.init(t,r),t._zod.pattern=B2,t._zod.parse=(i,s)=>{if(r.coerce)try{i.value=BigInt(i.value)}catch{}return typeof i.value=="bigint"||i.issues.push({expected:"bigint",code:"invalid_type",input:i.value,inst:t}),i}}),I3=j("$ZodUnknown",(t,r)=>{je.init(t,r),t._zod.parse=i=>i}),S3=j("$ZodNever",(t,r)=>{je.init(t,r),t._zod.parse=(i,s)=>(i.issues.push({expected:"never",code:"invalid_type",input:i.value,inst:t}),i)});function hf(t,r,i){t.issues.length&&r.issues.push(...Vr(i,t.issues)),r.value[i]=t.value}const k3=j("$ZodArray",(t,r)=>{je.init(t,r),t._zod.parse=(i,s)=>{const u=i.value;if(!Array.isArray(u))return i.issues.push({expected:"array",code:"invalid_type",input:u,inst:t}),i;i.value=Array(u.length);const p=[];for(let d=0;dhf(y,i,d))):hf(g,i,d)}return p.length?Promise.all(p).then(()=>i):i}});function Ea(t,r,i,s,u,p){const d=i in s;if(t.issues.length){if(u&&p&&!d)return;r.issues.push(...Vr(i,t.issues))}if(!d&&!u){t.issues.length||r.issues.push({code:"invalid_type",expected:"nonoptional",input:void 0,path:[i]});return}t.value===void 0?d&&(r.value[i]=void 0):r.value[i]=t.value}function Pm(t){const r=Object.keys(t.shape);for(const s of r)if(!t.shape?.[s]?._zod?.traits?.has("$ZodType"))throw new Error(`Invalid element at key "${s}": expected a Zod schema`);const i=M7(t.shape);return{...t,keys:r,keySet:new Set(r),numKeys:r.length,optionalKeys:new Set(i)}}function Nm(t,r,i,s,u,p){const d=[],m=u.keySet,g=u.catchall._zod,y=g.def.type,E=g.optin==="optional",S=g.optout==="optional";for(const T in r){if(T==="__proto__"||m.has(T))continue;if(y==="never"){d.push(T);continue}const A=g.run({value:r[T],issues:[]},s);A instanceof Promise?t.push(A.then(D=>Ea(D,i,T,r,E,S))):Ea(A,i,T,r,E,S)}return d.length&&i.issues.push({code:"unrecognized_keys",keys:d,input:r,inst:p}),t.length?Promise.all(t).then(()=>i):i}const b3=j("$ZodObject",(t,r)=>{if(je.init(t,r),!Object.getOwnPropertyDescriptor(r,"shape")?.get){const m=r.shape;Object.defineProperty(r,"shape",{get:()=>{const g={...m};return Object.defineProperty(r,"shape",{value:g}),g}})}const s=Na(()=>Pm(r));ze(t._zod,"propValues",()=>{const m=r.shape,g={};for(const y in m){const E=m[y]._zod;if(E.values){g[y]??(g[y]=new Set);for(const S of E.values)g[y].add(S)}}return g});const u=Qo,p=r.catchall;let d;t._zod.parse=(m,g)=>{d??(d=s.value);const y=m.value;if(!u(y))return m.issues.push({expected:"object",code:"invalid_type",input:y,inst:t}),m;m.value={};const E=[],S=d.shape;for(const T of d.keys){const A=S[T],D=A._zod.optin==="optional",W=A._zod.optout==="optional",O=A._zod.run({value:y[T],issues:[]},g);O instanceof Promise?E.push(O.then(H=>Ea(H,m,T,y,D,W))):Ea(O,m,T,y,D,W)}return p?Nm(E,y,m,g,s.value,t):E.length?Promise.all(E).then(()=>m):m}}),z3=j("$ZodObjectJIT",(t,r)=>{b3.init(t,r);const i=t._zod.parse,s=Na(()=>Pm(r)),u=T=>{const A=new H2(["shape","payload","ctx"]),D=s.value,W=Q=>{const G=mf(Q);return`shape[${G}]._zod.run({ value: input[${G}], issues: [] }, ctx)`};A.write("const input = payload.value;");const O=Object.create(null);let H=0;for(const Q of D.keys)O[Q]=`key_${H++}`;A.write("const newResult = {};");for(const Q of D.keys){const G=O[Q],ee=mf(Q),ue=T[Q],de=ue?._zod?.optin==="optional",pe=ue?._zod?.optout==="optional";A.write(`const ${G} = ${W(Q)};`),de&&pe?A.write(` - if (${G}.issues.length) { - if (${ee} in input) { - payload.issues = payload.issues.concat(${G}.issues.map(iss => ({ - ...iss, - path: iss.path ? [${ee}, ...iss.path] : [${ee}] - }))); - } - } - - if (${G}.value === undefined) { - if (${ee} in input) { - newResult[${ee}] = undefined; - } - } else { - newResult[${ee}] = ${G}.value; - } - - `):de?A.write(` - if (${G}.issues.length) { - payload.issues = payload.issues.concat(${G}.issues.map(iss => ({ - ...iss, - path: iss.path ? [${ee}, ...iss.path] : [${ee}] - }))); - } - - if (${G}.value === undefined) { - if (${ee} in input) { - newResult[${ee}] = undefined; - } - } else { - newResult[${ee}] = ${G}.value; - } - - `):A.write(` - const ${G}_present = ${ee} in input; - if (${G}.issues.length) { - payload.issues = payload.issues.concat(${G}.issues.map(iss => ({ - ...iss, - path: iss.path ? [${ee}, ...iss.path] : [${ee}] - }))); - } - if (!${G}_present && !${G}.issues.length) { - payload.issues.push({ - code: "invalid_type", - expected: "nonoptional", - input: undefined, - path: [${ee}] - }); - } - - if (${G}_present) { - if (${G}.value === undefined) { - newResult[${ee}] = undefined; - } else { - newResult[${ee}] = ${G}.value; - } - } - - `)}A.write("payload.value = newResult;"),A.write("return payload;");const oe=A.compile();return(Q,G)=>oe(T,Q,G)};let p;const d=Qo,m=!du.jitless,y=m&&L7.value,E=r.catchall;let S;t._zod.parse=(T,A)=>{S??(S=s.value);const D=T.value;return d(D)?m&&y&&A?.async===!1&&A.jitless!==!0?(p||(p=u(r.shape)),T=p(T,A),E?Nm([],D,T,A,S,t):T):i(T,A):(T.issues.push({expected:"object",code:"invalid_type",input:D,inst:t}),T)}});function gf(t,r,i,s){for(const p of t)if(p.issues.length===0)return r.value=p.value,r;const u=t.filter(p=>!qr(p));return u.length===1?(r.value=u[0].value,u[0]):(r.issues.push({code:"invalid_union",input:r.value,inst:i,errors:t.map(p=>p.issues.map(d=>hn(d,s,vn())))}),r)}const Am=j("$ZodUnion",(t,r)=>{je.init(t,r),ze(t._zod,"optin",()=>r.options.some(s=>s._zod.optin==="optional")?"optional":void 0),ze(t._zod,"optout",()=>r.options.some(s=>s._zod.optout==="optional")?"optional":void 0),ze(t._zod,"values",()=>{if(r.options.every(s=>s._zod.values))return new Set(r.options.flatMap(s=>Array.from(s._zod.values)))}),ze(t._zod,"pattern",()=>{if(r.options.every(s=>s._zod.pattern)){const s=r.options.map(u=>u._zod.pattern);return new RegExp(`^(${s.map(u=>fu(u.source)).join("|")})$`)}});const i=r.options.length===1?r.options[0]._zod.run:null;t._zod.parse=(s,u)=>{if(i)return i(s,u);let p=!1;const d=[];for(const m of r.options){const g=m._zod.run({value:s.value,issues:[]},u);if(g instanceof Promise)d.push(g),p=!0;else{if(g.issues.length===0)return g;d.push(g)}}return p?Promise.all(d).then(m=>gf(m,s,t,u)):gf(d,s,t,u)}}),C3=j("$ZodDiscriminatedUnion",(t,r)=>{r.inclusive=!1,Am.init(t,r);const i=t._zod.parse;ze(t._zod,"propValues",()=>{const u={};for(const p of r.options){const d=p._zod.propValues;if(!d||Object.keys(d).length===0)throw new Error(`Invalid discriminated union option at index "${r.options.indexOf(p)}"`);for(const[m,g]of Object.entries(d)){u[m]||(u[m]=new Set);for(const y of g)u[m].add(y)}}return u});const s=Na(()=>{const u=r.options,p=new Map;for(const d of u){const m=d._zod.propValues?.[r.discriminator];if(!m||m.size===0)throw new Error(`Invalid discriminated union option at index "${r.options.indexOf(d)}"`);for(const g of m){if(p.has(g))throw new Error(`Duplicate discriminator value "${String(g)}"`);p.set(g,d)}}return p});t._zod.parse=(u,p)=>{const d=u.value;if(!Qo(d))return u.issues.push({code:"invalid_type",expected:"object",input:d,inst:t}),u;const m=s.value.get(d?.[r.discriminator]);return m?m._zod.run(u,p):r.unionFallback||p.direction==="backward"?i(u,p):(u.issues.push({code:"invalid_union",errors:[],note:"No matching discriminator",discriminator:r.discriminator,options:Array.from(s.value.keys()),input:d,path:[r.discriminator],inst:t}),u)}}),T3=j("$ZodIntersection",(t,r)=>{je.init(t,r),t._zod.parse=(i,s)=>{const u=i.value,p=r.left._zod.run({value:u,issues:[]},s),d=r.right._zod.run({value:u,issues:[]},s);return p instanceof Promise||d instanceof Promise?Promise.all([p,d]).then(([g,y])=>yf(i,g,y)):yf(i,p,d)}});function Ql(t,r){if(t===r)return{valid:!0,data:t};if(t instanceof Date&&r instanceof Date&&+t==+r)return{valid:!0,data:t};if(Xr(t)&&Xr(r)){const i=Object.keys(r),s=Object.keys(t).filter(p=>i.indexOf(p)!==-1),u={...t,...r};for(const p of s){const d=Ql(t[p],r[p]);if(!d.valid)return{valid:!1,mergeErrorPath:[p,...d.mergeErrorPath]};u[p]=d.data}return{valid:!0,data:u}}if(Array.isArray(t)&&Array.isArray(r)){if(t.length!==r.length)return{valid:!1,mergeErrorPath:[]};const i=[];for(let s=0;sm.l&&m.r).map(([m])=>m);if(p.length&&u&&t.issues.push({...u,keys:p}),qr(t))return t;const d=Ql(r.value,i.value);if(!d.valid)throw new Error(`Unmergable intersection. Error path: ${JSON.stringify(d.mergeErrorPath)}`);return t.value=d.data,t}const B3=j("$ZodRecord",(t,r)=>{je.init(t,r),t._zod.parse=(i,s)=>{const u=i.value;if(!Xr(u))return i.issues.push({expected:"record",code:"invalid_type",input:u,inst:t}),i;const p=[],d=r.keyType._zod.values;if(d){i.value={};const m=new Set;for(const y of d)if(typeof y=="string"||typeof y=="number"||typeof y=="symbol"){m.add(typeof y=="number"?y.toString():y);const E=r.keyType._zod.run({value:y,issues:[]},s);if(E instanceof Promise)throw new Error("Async schemas not supported in object keys currently");if(E.issues.length){i.issues.push({code:"invalid_key",origin:"record",issues:E.issues.map(A=>hn(A,s,vn())),input:y,path:[y],inst:t});continue}const S=E.value,T=r.valueType._zod.run({value:u[y],issues:[]},s);T instanceof Promise?p.push(T.then(A=>{A.issues.length&&i.issues.push(...Vr(y,A.issues)),i.value[S]=A.value})):(T.issues.length&&i.issues.push(...Vr(y,T.issues)),i.value[S]=T.value)}let g;for(const y in u)m.has(y)||(g=g??[],g.push(y));g&&g.length>0&&i.issues.push({code:"unrecognized_keys",input:u,inst:t,keys:g})}else{i.value={};for(const m of Reflect.ownKeys(u)){if(m==="__proto__"||!Object.prototype.propertyIsEnumerable.call(u,m))continue;let g=r.keyType._zod.run({value:m,issues:[]},s);if(g instanceof Promise)throw new Error("Async schemas not supported in object keys currently");if(typeof m=="string"&&bm.test(m)&&g.issues.length){const S=r.keyType._zod.run({value:Number(m),issues:[]},s);if(S instanceof Promise)throw new Error("Async schemas not supported in object keys currently");S.issues.length===0&&(g=S)}if(g.issues.length){r.mode==="loose"?i.value[m]=u[m]:i.issues.push({code:"invalid_key",origin:"record",issues:g.issues.map(S=>hn(S,s,vn())),input:m,path:[m],inst:t});continue}const E=r.valueType._zod.run({value:u[m],issues:[]},s);E instanceof Promise?p.push(E.then(S=>{S.issues.length&&i.issues.push(...Vr(m,S.issues)),i.value[g.value]=S.value})):(E.issues.length&&i.issues.push(...Vr(m,E.issues)),i.value[g.value]=E.value)}}return p.length?Promise.all(p).then(()=>i):i}}),R3=j("$ZodEnum",(t,r)=>{je.init(t,r);const i=gm(r.entries),s=new Set(i);t._zod.values=s,t._zod.pattern=new RegExp(`^(${i.filter(u=>D7.has(typeof u)).map(u=>typeof u=="string"?eo(u):u.toString()).join("|")})$`),t._zod.parse=(u,p)=>{const d=u.value;return s.has(d)||u.issues.push({code:"invalid_value",values:i,input:d,inst:t}),u}}),P3=j("$ZodLiteral",(t,r)=>{if(je.init(t,r),r.values.length===0)throw new Error("Cannot create literal schema with no valid values");const i=new Set(r.values);t._zod.values=i,t._zod.pattern=new RegExp(`^(${r.values.map(s=>typeof s=="string"?eo(s):s?eo(s.toString()):String(s)).join("|")})$`),t._zod.parse=(s,u)=>{const p=s.value;return i.has(p)||s.issues.push({code:"invalid_value",values:r.values,input:p,inst:t}),s}}),N3=j("$ZodTransform",(t,r)=>{je.init(t,r),t._zod.optin="optional",t._zod.parse=(i,s)=>{if(s.direction==="backward")throw new hm(t.constructor.name);const u=r.transform(i.value,i);if(s.async)return(u instanceof Promise?u:Promise.resolve(u)).then(d=>(i.value=d,i.fallback=!0,i));if(u instanceof Promise)throw new Hr;return i.value=u,i.fallback=!0,i}});function _f(t,r){return r===void 0&&(t.issues.length||t.fallback)?{issues:[],value:void 0}:t}const Om=j("$ZodOptional",(t,r)=>{je.init(t,r),t._zod.optin="optional",t._zod.optout="optional",ze(t._zod,"values",()=>r.innerType._zod.values?new Set([...r.innerType._zod.values,void 0]):void 0),ze(t._zod,"pattern",()=>{const i=r.innerType._zod.pattern;return i?new RegExp(`^(${fu(i.source)})?$`):void 0}),t._zod.parse=(i,s)=>{if(r.innerType._zod.optin==="optional"){const u=i.value,p=r.innerType._zod.run(i,s);return p instanceof Promise?p.then(d=>_f(d,u)):_f(p,u)}return i.value===void 0?i:r.innerType._zod.run(i,s)}}),A3=j("$ZodExactOptional",(t,r)=>{Om.init(t,r),ze(t._zod,"values",()=>r.innerType._zod.values),ze(t._zod,"pattern",()=>r.innerType._zod.pattern),t._zod.parse=(i,s)=>r.innerType._zod.run(i,s)}),O3=j("$ZodNullable",(t,r)=>{je.init(t,r),ze(t._zod,"optin",()=>r.innerType._zod.optin),ze(t._zod,"optout",()=>r.innerType._zod.optout),ze(t._zod,"pattern",()=>{const i=r.innerType._zod.pattern;return i?new RegExp(`^(${fu(i.source)}|null)$`):void 0}),ze(t._zod,"values",()=>r.innerType._zod.values?new Set([...r.innerType._zod.values,null]):void 0),t._zod.parse=(i,s)=>i.value===null?i:r.innerType._zod.run(i,s)}),j3=j("$ZodDefault",(t,r)=>{je.init(t,r),t._zod.optin="optional",ze(t._zod,"values",()=>r.innerType._zod.values),t._zod.parse=(i,s)=>{if(s.direction==="backward")return r.innerType._zod.run(i,s);if(i.value===void 0)return i.value=r.defaultValue,i;const u=r.innerType._zod.run(i,s);return u instanceof Promise?u.then(p=>wf(p,r)):wf(u,r)}});function wf(t,r){return t.value===void 0&&(t.value=r.defaultValue),t}const $3=j("$ZodPrefault",(t,r)=>{je.init(t,r),t._zod.optin="optional",ze(t._zod,"values",()=>r.innerType._zod.values),t._zod.parse=(i,s)=>(s.direction==="backward"||i.value===void 0&&(i.value=r.defaultValue),r.innerType._zod.run(i,s))}),L3=j("$ZodNonOptional",(t,r)=>{je.init(t,r),ze(t._zod,"values",()=>{const i=r.innerType._zod.values;return i?new Set([...i].filter(s=>s!==void 0)):void 0}),t._zod.parse=(i,s)=>{const u=r.innerType._zod.run(i,s);return u instanceof Promise?u.then(p=>xf(p,t)):xf(u,t)}});function xf(t,r){return!t.issues.length&&t.value===void 0&&t.issues.push({code:"invalid_type",expected:"nonoptional",input:t.value,inst:r}),t}const D3=j("$ZodCatch",(t,r)=>{je.init(t,r),t._zod.optin="optional",ze(t._zod,"optout",()=>r.innerType._zod.optout),ze(t._zod,"values",()=>r.innerType._zod.values),t._zod.parse=(i,s)=>{if(s.direction==="backward")return r.innerType._zod.run(i,s);const u=r.innerType._zod.run(i,s);return u instanceof Promise?u.then(p=>(i.value=p.value,p.issues.length&&(i.value=r.catchValue({...i,error:{issues:p.issues.map(d=>hn(d,s,vn()))},input:i.value}),i.issues=[],i.fallback=!0),i)):(i.value=u.value,u.issues.length&&(i.value=r.catchValue({...i,error:{issues:u.issues.map(p=>hn(p,s,vn()))},input:i.value}),i.issues=[],i.fallback=!0),i)}}),M3=j("$ZodPipe",(t,r)=>{je.init(t,r),ze(t._zod,"values",()=>r.in._zod.values),ze(t._zod,"optin",()=>r.in._zod.optin),ze(t._zod,"optout",()=>r.out._zod.optout),ze(t._zod,"propValues",()=>r.in._zod.propValues),t._zod.parse=(i,s)=>{if(s.direction==="backward"){const p=r.out._zod.run(i,s);return p instanceof Promise?p.then(d=>ga(d,r.in,s)):ga(p,r.in,s)}const u=r.in._zod.run(i,s);return u instanceof Promise?u.then(p=>ga(p,r.out,s)):ga(u,r.out,s)}});function ga(t,r,i){return t.issues.length?(t.aborted=!0,t):r._zod.run({value:t.value,issues:t.issues,fallback:t.fallback},i)}const F3=j("$ZodReadonly",(t,r)=>{je.init(t,r),ze(t._zod,"propValues",()=>r.innerType._zod.propValues),ze(t._zod,"values",()=>r.innerType._zod.values),ze(t._zod,"optin",()=>r.innerType?._zod?.optin),ze(t._zod,"optout",()=>r.innerType?._zod?.optout),t._zod.parse=(i,s)=>{if(s.direction==="backward")return r.innerType._zod.run(i,s);const u=r.innerType._zod.run(i,s);return u instanceof Promise?u.then(Ef):Ef(u)}});function Ef(t){return t.value=Object.freeze(t.value),t}const U3=j("$ZodCustom",(t,r)=>{St.init(t,r),je.init(t,r),t._zod.parse=(i,s)=>i,t._zod.check=i=>{const s=i.value,u=r.fn(s);if(u instanceof Promise)return u.then(p=>If(p,i,s,t));If(u,i,s,t)}});function If(t,r,i,s){if(!t){const u={code:"custom",input:i,inst:s,path:[...s._zod.def.path??[]],continue:!s._zod.def.abort};s._zod.def.params&&(u.params=s._zod.def.params),r.issues.push(Yo(u))}}var Sf;class Z3{constructor(){this._map=new WeakMap,this._idmap=new Map}add(r,...i){const s=i[0];return this._map.set(r,s),s&&typeof s=="object"&&"id"in s&&this._idmap.set(s.id,r),this}clear(){return this._map=new WeakMap,this._idmap=new Map,this}remove(r){const i=this._map.get(r);return i&&typeof i=="object"&&"id"in i&&this._idmap.delete(i.id),this._map.delete(r),this}get(r){const i=r._zod.parent;if(i){const s={...this.get(i)??{}};delete s.id;const u={...s,...this._map.get(r)};return Object.keys(u).length?u:void 0}return this._map.get(r)}has(r){return this._map.has(r)}}function q3(){return new Z3}(Sf=globalThis).__zod_globalRegistry??(Sf.__zod_globalRegistry=q3());const Wo=globalThis.__zod_globalRegistry;function V3(t,r){return new t({type:"string",...ie(r)})}function W3(t,r){return new t({type:"string",format:"email",check:"string_format",abort:!1,...ie(r)})}function kf(t,r){return new t({type:"string",format:"guid",check:"string_format",abort:!1,...ie(r)})}function H3(t,r){return new t({type:"string",format:"uuid",check:"string_format",abort:!1,...ie(r)})}function G3(t,r){return new t({type:"string",format:"uuid",check:"string_format",abort:!1,version:"v4",...ie(r)})}function J3(t,r){return new t({type:"string",format:"uuid",check:"string_format",abort:!1,version:"v6",...ie(r)})}function K3(t,r){return new t({type:"string",format:"uuid",check:"string_format",abort:!1,version:"v7",...ie(r)})}function jm(t,r){return new t({type:"string",format:"url",check:"string_format",abort:!1,...ie(r)})}function Q3(t,r){return new t({type:"string",format:"emoji",check:"string_format",abort:!1,...ie(r)})}function Y3(t,r){return new t({type:"string",format:"nanoid",check:"string_format",abort:!1,...ie(r)})}function X3(t,r){return new t({type:"string",format:"cuid",check:"string_format",abort:!1,...ie(r)})}function e_(t,r){return new t({type:"string",format:"cuid2",check:"string_format",abort:!1,...ie(r)})}function t_(t,r){return new t({type:"string",format:"ulid",check:"string_format",abort:!1,...ie(r)})}function n_(t,r){return new t({type:"string",format:"xid",check:"string_format",abort:!1,...ie(r)})}function r_(t,r){return new t({type:"string",format:"ksuid",check:"string_format",abort:!1,...ie(r)})}function o_(t,r){return new t({type:"string",format:"ipv4",check:"string_format",abort:!1,...ie(r)})}function i_(t,r){return new t({type:"string",format:"ipv6",check:"string_format",abort:!1,...ie(r)})}function a_(t,r){return new t({type:"string",format:"cidrv4",check:"string_format",abort:!1,...ie(r)})}function s_(t,r){return new t({type:"string",format:"cidrv6",check:"string_format",abort:!1,...ie(r)})}function l_(t,r){return new t({type:"string",format:"base64",check:"string_format",abort:!1,...ie(r)})}function u_(t,r){return new t({type:"string",format:"base64url",check:"string_format",abort:!1,...ie(r)})}function c_(t,r){return new t({type:"string",format:"e164",check:"string_format",abort:!1,...ie(r)})}function d_(t,r){return new t({type:"string",format:"jwt",check:"string_format",abort:!1,...ie(r)})}function p_(t,r){return new t({type:"string",format:"datetime",check:"string_format",offset:!1,local:!1,precision:null,...ie(r)})}function f_(t,r){return new t({type:"string",format:"date",check:"string_format",...ie(r)})}function m_(t,r){return new t({type:"string",format:"time",check:"string_format",precision:null,...ie(r)})}function v_(t,r){return new t({type:"string",format:"duration",check:"string_format",...ie(r)})}function h_(t,r){return new t({type:"number",checks:[],...ie(r)})}function g_(t,r){return new t({type:"number",check:"number_format",abort:!1,format:"safeint",...ie(r)})}function y_(t,r){return new t({type:"boolean",...ie(r)})}function __(t,r){return new t({type:"bigint",coerce:!0,...ie(r)})}function w_(t){return new t({type:"unknown"})}function x_(t,r){return new t({type:"never",...ie(r)})}function Ia(t,r){return new Cm({check:"less_than",...ie(r),value:t,inclusive:!1})}function Gr(t,r){return new Cm({check:"less_than",...ie(r),value:t,inclusive:!0})}function Sa(t,r){return new Tm({check:"greater_than",...ie(r),value:t,inclusive:!1})}function Un(t,r){return new Tm({check:"greater_than",...ie(r),value:t,inclusive:!0})}function Yl(t,r){return new O2({check:"multiple_of",...ie(r),value:t})}function $m(t,r){return new $2({check:"max_length",...ie(r),maximum:t})}function ka(t,r){return new L2({check:"min_length",...ie(r),minimum:t})}function Lm(t,r){return new D2({check:"length_equals",...ie(r),length:t})}function E_(t,r){return new M2({check:"string_format",format:"regex",...ie(r),pattern:t})}function I_(t){return new F2({check:"string_format",format:"lowercase",...ie(t)})}function S_(t){return new U2({check:"string_format",format:"uppercase",...ie(t)})}function k_(t,r){return new Z2({check:"string_format",format:"includes",...ie(r),includes:t})}function b_(t,r){return new q2({check:"string_format",format:"starts_with",...ie(r),prefix:t})}function z_(t,r){return new V2({check:"string_format",format:"ends_with",...ie(r),suffix:t})}function ro(t){return new W2({check:"overwrite",tx:t})}function C_(t){return ro(r=>r.normalize(t))}function T_(){return ro(t=>t.trim())}function B_(){return ro(t=>t.toLowerCase())}function R_(){return ro(t=>t.toUpperCase())}function P_(){return ro(t=>$7(t))}function N_(t,r,i){return new t({type:"array",element:r,...ie(i)})}function A_(t,r,i){return new t({type:"custom",check:"custom",fn:r,...ie(i)})}function O_(t,r){const i=j_(s=>(s.addIssue=u=>{if(typeof u=="string")s.issues.push(Yo(u,s.value,i._zod.def));else{const p=u;p.fatal&&(p.continue=!1),p.code??(p.code="custom"),p.input??(p.input=s.value),p.inst??(p.inst=i),p.continue??(p.continue=!i._zod.def.abort),s.issues.push(Yo(p))}},t(s.value,s)),r);return i}function j_(t,r){const i=new St({check:"custom",...ie(r)});return i._zod.check=t,i}function Dm(t){let r=t?.target??"draft-2020-12";return r==="draft-4"&&(r="draft-04"),r==="draft-7"&&(r="draft-07"),{processors:t.processors??{},metadataRegistry:t?.metadata??Wo,target:r,unrepresentable:t?.unrepresentable??"throw",override:t?.override??(()=>{}),io:t?.io??"output",counter:0,seen:new Map,cycles:t?.cycles??"ref",reused:t?.reused??"inline",external:t?.external??void 0}}function Je(t,r,i={path:[],schemaPath:[]}){var s;const u=t._zod.def,p=r.seen.get(t);if(p)return p.count++,i.schemaPath.includes(t)&&(p.cycle=i.path),p.schema;const d={schema:{},count:1,cycle:void 0,path:i.path};r.seen.set(t,d);const m=t._zod.toJSONSchema?.();if(m)d.schema=m;else{const E={...i,schemaPath:[...i.schemaPath,t],path:i.path};if(t._zod.processJSONSchema)t._zod.processJSONSchema(r,d.schema,E);else{const T=d.schema,A=r.processors[u.type];if(!A)throw new Error(`[toJSONSchema]: Non-representable type encountered: ${u.type}`);A(t,r,T,E)}const S=t._zod.parent;S&&(d.ref||(d.ref=S),Je(S,r,E),r.seen.get(S).isParent=!0)}const g=r.metadataRegistry.get(t);return g&&Object.assign(d.schema,g),r.io==="input"&&ft(t)&&(delete d.schema.examples,delete d.schema.default),r.io==="input"&&"_prefault"in d.schema&&((s=d.schema).default??(s.default=d.schema._prefault)),delete d.schema._prefault,r.seen.get(t).schema}function Mm(t,r){const i=t.seen.get(r);if(!i)throw new Error("Unprocessed schema. This is a bug in Zod.");const s=new Map;for(const d of t.seen.entries()){const m=t.metadataRegistry.get(d[0])?.id;if(m){const g=s.get(m);if(g&&g!==d[0])throw new Error(`Duplicate schema id "${m}" detected during JSON Schema conversion. Two different schemas cannot share the same id when converted together.`);s.set(m,d[0])}}const u=d=>{const m=t.target==="draft-2020-12"?"$defs":"definitions";if(t.external){const S=t.external.registry.get(d[0])?.id,T=t.external.uri??(D=>D);if(S)return{ref:T(S)};const A=d[1].defId??d[1].schema.id??`schema${t.counter++}`;return d[1].defId=A,{defId:A,ref:`${T("__shared")}#/${m}/${A}`}}if(d[1]===i)return{ref:"#"};const y=`#/${m}/`,E=d[1].schema.id??`__schema${t.counter++}`;return{defId:E,ref:y+E}},p=d=>{if(d[1].schema.$ref)return;const m=d[1],{ref:g,defId:y}=u(d);m.def={...m.schema},y&&(m.defId=y);const E=m.schema;for(const S in E)delete E[S];E.$ref=g};if(t.cycles==="throw")for(const d of t.seen.entries()){const m=d[1];if(m.cycle)throw new Error(`Cycle detected: #/${m.cycle?.join("/")}/ - -Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.`)}for(const d of t.seen.entries()){const m=d[1];if(r===d[0]){p(d);continue}if(t.external){const y=t.external.registry.get(d[0])?.id;if(r!==d[0]&&y){p(d);continue}}if(t.metadataRegistry.get(d[0])?.id){p(d);continue}if(m.cycle){p(d);continue}if(m.count>1&&t.reused==="ref"){p(d);continue}}}function Fm(t,r){const i=t.seen.get(r);if(!i)throw new Error("Unprocessed schema. This is a bug in Zod.");const s=m=>{const g=t.seen.get(m);if(g.ref===null)return;const y=g.def??g.schema,E={...y},S=g.ref;if(g.ref=null,S){s(S);const A=t.seen.get(S),D=A.schema;if(D.$ref&&(t.target==="draft-07"||t.target==="draft-04"||t.target==="openapi-3.0")?(y.allOf=y.allOf??[],y.allOf.push(D)):Object.assign(y,D),Object.assign(y,E),m._zod.parent===S)for(const O in y)O==="$ref"||O==="allOf"||O in E||delete y[O];if(D.$ref&&A.def)for(const O in y)O==="$ref"||O==="allOf"||O in A.def&&JSON.stringify(y[O])===JSON.stringify(A.def[O])&&delete y[O]}const T=m._zod.parent;if(T&&T!==S){s(T);const A=t.seen.get(T);if(A?.schema.$ref&&(y.$ref=A.schema.$ref,A.def))for(const D in y)D==="$ref"||D==="allOf"||D in A.def&&JSON.stringify(y[D])===JSON.stringify(A.def[D])&&delete y[D]}t.override({zodSchema:m,jsonSchema:y,path:g.path??[]})};for(const m of[...t.seen.entries()].reverse())s(m[0]);const u={};if(t.target==="draft-2020-12"?u.$schema="https://json-schema.org/draft/2020-12/schema":t.target==="draft-07"?u.$schema="http://json-schema.org/draft-07/schema#":t.target==="draft-04"?u.$schema="http://json-schema.org/draft-04/schema#":t.target,t.external?.uri){const m=t.external.registry.get(r)?.id;if(!m)throw new Error("Schema is missing an `id` property");u.$id=t.external.uri(m)}Object.assign(u,i.def??i.schema);const p=t.metadataRegistry.get(r)?.id;p!==void 0&&u.id===p&&delete u.id;const d=t.external?.defs??{};for(const m of t.seen.entries()){const g=m[1];g.def&&g.defId&&(g.def.id===g.defId&&delete g.def.id,d[g.defId]=g.def)}t.external||Object.keys(d).length>0&&(t.target==="draft-2020-12"?u.$defs=d:u.definitions=d);try{const m=JSON.parse(JSON.stringify(u));return Object.defineProperty(m,"~standard",{value:{...r["~standard"],jsonSchema:{input:ba(r,"input",t.processors),output:ba(r,"output",t.processors)}},enumerable:!1,writable:!1}),m}catch{throw new Error("Error converting schema to JSON.")}}function ft(t,r){const i=r??{seen:new Set};if(i.seen.has(t))return!1;i.seen.add(t);const s=t._zod.def;if(s.type==="transform")return!0;if(s.type==="array")return ft(s.element,i);if(s.type==="set")return ft(s.valueType,i);if(s.type==="lazy")return ft(s.getter(),i);if(s.type==="promise"||s.type==="optional"||s.type==="nonoptional"||s.type==="nullable"||s.type==="readonly"||s.type==="default"||s.type==="prefault")return ft(s.innerType,i);if(s.type==="intersection")return ft(s.left,i)||ft(s.right,i);if(s.type==="record"||s.type==="map")return ft(s.keyType,i)||ft(s.valueType,i);if(s.type==="pipe")return t._zod.traits.has("$ZodCodec")?!0:ft(s.in,i)||ft(s.out,i);if(s.type==="object"){for(const u in s.shape)if(ft(s.shape[u],i))return!0;return!1}if(s.type==="union"){for(const u of s.options)if(ft(u,i))return!0;return!1}if(s.type==="tuple"){for(const u of s.items)if(ft(u,i))return!0;return!!(s.rest&&ft(s.rest,i))}return!1}const $_=(t,r={})=>i=>{const s=Dm({...i,processors:r});return Je(t,s),Mm(s,t),Fm(s,t)},ba=(t,r,i={})=>s=>{const{libraryOptions:u,target:p}=s??{},d=Dm({...u??{},target:p,io:r,processors:i});return Je(t,d),Mm(d,t),Fm(d,t)},L_={guid:"uuid",url:"uri",datetime:"date-time",json_string:"json-string",regex:""},D_=(t,r,i,s)=>{const u=i;u.type="string";const{minimum:p,maximum:d,format:m,patterns:g,contentEncoding:y}=t._zod.bag;if(typeof p=="number"&&(u.minLength=p),typeof d=="number"&&(u.maxLength=d),m&&(u.format=L_[m]??m,u.format===""&&delete u.format,m==="time"&&delete u.format),y&&(u.contentEncoding=y),g&&g.size>0){const E=[...g];E.length===1?u.pattern=E[0].source:E.length>1&&(u.allOf=[...E.map(S=>({...r.target==="draft-07"||r.target==="draft-04"||r.target==="openapi-3.0"?{type:"string"}:{},pattern:S.source}))])}},M_=(t,r,i,s)=>{const u=i,{minimum:p,maximum:d,format:m,multipleOf:g,exclusiveMaximum:y,exclusiveMinimum:E}=t._zod.bag;typeof m=="string"&&m.includes("int")?u.type="integer":u.type="number";const S=typeof E=="number"&&E>=(p??Number.NEGATIVE_INFINITY),T=typeof y=="number"&&y<=(d??Number.POSITIVE_INFINITY),A=r.target==="draft-04"||r.target==="openapi-3.0";S?A?(u.minimum=E,u.exclusiveMinimum=!0):u.exclusiveMinimum=E:typeof p=="number"&&(u.minimum=p),T?A?(u.maximum=y,u.exclusiveMaximum=!0):u.exclusiveMaximum=y:typeof d=="number"&&(u.maximum=d),typeof g=="number"&&(u.multipleOf=g)},F_=(t,r,i,s)=>{i.type="boolean"},U_=(t,r,i,s)=>{if(r.unrepresentable==="throw")throw new Error("BigInt cannot be represented in JSON Schema")},Z_=(t,r,i,s)=>{i.not={}},q_=(t,r,i,s)=>{},V_=(t,r,i,s)=>{const u=t._zod.def,p=gm(u.entries);p.every(d=>typeof d=="number")&&(i.type="number"),p.every(d=>typeof d=="string")&&(i.type="string"),i.enum=p},W_=(t,r,i,s)=>{const u=t._zod.def,p=[];for(const d of u.values)if(d===void 0){if(r.unrepresentable==="throw")throw new Error("Literal `undefined` cannot be represented in JSON Schema")}else if(typeof d=="bigint"){if(r.unrepresentable==="throw")throw new Error("BigInt literals cannot be represented in JSON Schema");p.push(Number(d))}else p.push(d);if(p.length!==0)if(p.length===1){const d=p[0];i.type=d===null?"null":typeof d,r.target==="draft-04"||r.target==="openapi-3.0"?i.enum=[d]:i.const=d}else p.every(d=>typeof d=="number")&&(i.type="number"),p.every(d=>typeof d=="string")&&(i.type="string"),p.every(d=>typeof d=="boolean")&&(i.type="boolean"),p.every(d=>d===null)&&(i.type="null"),i.enum=p},H_=(t,r,i,s)=>{if(r.unrepresentable==="throw")throw new Error("Custom types cannot be represented in JSON Schema")},G_=(t,r,i,s)=>{if(r.unrepresentable==="throw")throw new Error("Transforms cannot be represented in JSON Schema")},J_=(t,r,i,s)=>{const u=i,p=t._zod.def,{minimum:d,maximum:m}=t._zod.bag;typeof d=="number"&&(u.minItems=d),typeof m=="number"&&(u.maxItems=m),u.type="array",u.items=Je(p.element,r,{...s,path:[...s.path,"items"]})},K_=(t,r,i,s)=>{const u=i,p=t._zod.def;u.type="object",u.properties={};const d=p.shape;for(const y in d)u.properties[y]=Je(d[y],r,{...s,path:[...s.path,"properties",y]});const m=new Set(Object.keys(d)),g=new Set([...m].filter(y=>{const E=p.shape[y]._zod;return r.io==="input"?E.optin===void 0:E.optout===void 0}));g.size>0&&(u.required=Array.from(g)),p.catchall?._zod.def.type==="never"?u.additionalProperties=!1:p.catchall?p.catchall&&(u.additionalProperties=Je(p.catchall,r,{...s,path:[...s.path,"additionalProperties"]})):r.io==="output"&&(u.additionalProperties=!1)},Q_=(t,r,i,s)=>{const u=t._zod.def,p=u.inclusive===!1,d=u.options.map((m,g)=>Je(m,r,{...s,path:[...s.path,p?"oneOf":"anyOf",g]}));p?i.oneOf=d:i.anyOf=d},Y_=(t,r,i,s)=>{const u=t._zod.def,p=Je(u.left,r,{...s,path:[...s.path,"allOf",0]}),d=Je(u.right,r,{...s,path:[...s.path,"allOf",1]}),m=y=>"allOf"in y&&Object.keys(y).length===1,g=[...m(p)?p.allOf:[p],...m(d)?d.allOf:[d]];i.allOf=g},X_=(t,r,i,s)=>{const u=i,p=t._zod.def;u.type="object";const d=p.keyType,g=d._zod.bag?.patterns;if(p.mode==="loose"&&g&&g.size>0){const E=Je(p.valueType,r,{...s,path:[...s.path,"patternProperties","*"]});u.patternProperties={};for(const S of g)u.patternProperties[S.source]=E}else(r.target==="draft-07"||r.target==="draft-2020-12")&&(u.propertyNames=Je(p.keyType,r,{...s,path:[...s.path,"propertyNames"]})),u.additionalProperties=Je(p.valueType,r,{...s,path:[...s.path,"additionalProperties"]});const y=d._zod.values;if(y){const E=[...y].filter(S=>typeof S=="string"||typeof S=="number");E.length>0&&(u.required=E)}},e8=(t,r,i,s)=>{const u=t._zod.def,p=Je(u.innerType,r,s),d=r.seen.get(t);r.target==="openapi-3.0"?(d.ref=u.innerType,i.nullable=!0):i.anyOf=[p,{type:"null"}]},t8=(t,r,i,s)=>{const u=t._zod.def;Je(u.innerType,r,s);const p=r.seen.get(t);p.ref=u.innerType},n8=(t,r,i,s)=>{const u=t._zod.def;Je(u.innerType,r,s);const p=r.seen.get(t);p.ref=u.innerType,i.default=JSON.parse(JSON.stringify(u.defaultValue))},r8=(t,r,i,s)=>{const u=t._zod.def;Je(u.innerType,r,s);const p=r.seen.get(t);p.ref=u.innerType,r.io==="input"&&(i._prefault=JSON.parse(JSON.stringify(u.defaultValue)))},o8=(t,r,i,s)=>{const u=t._zod.def;Je(u.innerType,r,s);const p=r.seen.get(t);p.ref=u.innerType;let d;try{d=u.catchValue(void 0)}catch{throw new Error("Dynamic catch values are not supported in JSON Schema")}i.default=d},i8=(t,r,i,s)=>{const u=t._zod.def,p=u.in._zod.traits.has("$ZodTransform"),d=r.io==="input"?p?u.out:u.in:u.out;Je(d,r,s);const m=r.seen.get(t);m.ref=d},a8=(t,r,i,s)=>{const u=t._zod.def;Je(u.innerType,r,s);const p=r.seen.get(t);p.ref=u.innerType,i.readOnly=!0},Um=(t,r,i,s)=>{const u=t._zod.def;Je(u.innerType,r,s);const p=r.seen.get(t);p.ref=u.innerType},s8=j("ZodISODateTime",(t,r)=>{a3.init(t,r),Ue.init(t,r)});function N(t){return p_(s8,t)}const l8=j("ZodISODate",(t,r)=>{s3.init(t,r),Ue.init(t,r)});function u8(t){return f_(l8,t)}const c8=j("ZodISOTime",(t,r)=>{l3.init(t,r),Ue.init(t,r)});function d8(t){return m_(c8,t)}const p8=j("ZodISODuration",(t,r)=>{u3.init(t,r),Ue.init(t,r)});function f8(t){return v_(p8,t)}const m8=(t,r)=>{xm.init(t,r),t.name="ZodError",Object.defineProperties(t,{format:{value:i=>Q7(t,i)},flatten:{value:i=>K7(t,i)},addIssue:{value:i=>{t.issues.push(i),t.message=JSON.stringify(t.issues,Kl,2)}},addIssues:{value:i=>{t.issues.push(...i),t.message=JSON.stringify(t.issues,Kl,2)}},isEmpty:{get(){return t.issues.length===0}}})},Ft=j("ZodError",m8,{Parent:Error}),v8=vu(Ft),h8=hu(Ft),g8=Aa(Ft),y8=Oa(Ft),_8=e2(Ft),w8=t2(Ft),x8=n2(Ft),E8=r2(Ft),I8=o2(Ft),S8=i2(Ft),k8=a2(Ft),b8=s2(Ft),bf=new WeakMap;function ei(t,r,i){const s=Object.getPrototypeOf(t);let u=bf.get(s);if(u||(u=new Set,bf.set(s,u)),!u.has(r)){u.add(r);for(const p in i){const d=i[p];Object.defineProperty(s,p,{configurable:!0,enumerable:!1,get(){const m=d.bind(this);return Object.defineProperty(this,p,{configurable:!0,writable:!0,enumerable:!0,value:m}),m},set(m){Object.defineProperty(this,p,{configurable:!0,writable:!0,enumerable:!0,value:m})}})}}}const Le=j("ZodType",(t,r)=>(je.init(t,r),Object.assign(t["~standard"],{jsonSchema:{input:ba(t,"input"),output:ba(t,"output")}}),t.toJSONSchema=$_(t,{}),t.def=r,t.type=r.type,Object.defineProperty(t,"_def",{value:r}),t.parse=(i,s)=>v8(t,i,s,{callee:t.parse}),t.safeParse=(i,s)=>g8(t,i,s),t.parseAsync=async(i,s)=>h8(t,i,s,{callee:t.parseAsync}),t.safeParseAsync=async(i,s)=>y8(t,i,s),t.spa=t.safeParseAsync,t.encode=(i,s)=>_8(t,i,s),t.decode=(i,s)=>w8(t,i,s),t.encodeAsync=async(i,s)=>x8(t,i,s),t.decodeAsync=async(i,s)=>E8(t,i,s),t.safeEncode=(i,s)=>I8(t,i,s),t.safeDecode=(i,s)=>S8(t,i,s),t.safeEncodeAsync=async(i,s)=>k8(t,i,s),t.safeDecodeAsync=async(i,s)=>b8(t,i,s),ei(t,"ZodType",{check(...i){const s=this.def;return this.clone(Kn(s,{checks:[...s.checks??[],...i.map(u=>typeof u=="function"?{_zod:{check:u,def:{check:"custom"},onattach:[]}}:u)]}),{parent:!0})},with(...i){return this.check(...i)},clone(i,s){return Qn(this,i,s)},brand(){return this},register(i,s){return i.add(this,s),this},refine(i,s){return this.check(gw(i,s))},superRefine(i,s){return this.check(yw(i,s))},overwrite(i){return this.check(ro(i))},optional(){return Bf(this)},exactOptional(){return ow(this)},nullable(){return Rf(this)},nullish(){return Bf(Rf(this))},nonoptional(i){return cw(this,i)},array(){return P(this)},or(i){return Yn([this,i])},and(i){return X8(this,i)},transform(i){return Pf(this,nw(i))},default(i){return sw(this,i)},prefault(i){return uw(this,i)},catch(i){return pw(this,i)},pipe(i){return Pf(this,i)},readonly(){return vw(this)},describe(i){const s=this.clone();return Wo.add(s,{description:i}),s},meta(...i){if(i.length===0)return Wo.get(this);const s=this.clone();return Wo.add(s,i[0]),s},isOptional(){return this.safeParse(void 0).success},isNullable(){return this.safeParse(null).success},apply(i){return i(this)}}),Object.defineProperty(t,"description",{get(){return Wo.get(t)?.description},configurable:!0}),t)),Zm=j("_ZodString",(t,r)=>{gu.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(s,u,p)=>D_(t,s,u);const i=t._zod.bag;t.format=i.format??null,t.minLength=i.minimum??null,t.maxLength=i.maximum??null,ei(t,"_ZodString",{regex(...s){return this.check(E_(...s))},includes(...s){return this.check(k_(...s))},startsWith(...s){return this.check(b_(...s))},endsWith(...s){return this.check(z_(...s))},min(...s){return this.check(ka(...s))},max(...s){return this.check($m(...s))},length(...s){return this.check(Lm(...s))},nonempty(...s){return this.check(ka(1,...s))},lowercase(s){return this.check(I_(s))},uppercase(s){return this.check(S_(s))},trim(){return this.check(T_())},normalize(...s){return this.check(C_(...s))},toLowerCase(){return this.check(B_())},toUpperCase(){return this.check(R_())},slugify(){return this.check(P_())}})}),z8=j("ZodString",(t,r)=>{gu.init(t,r),Zm.init(t,r),t.email=i=>t.check(W3(C8,i)),t.url=i=>t.check(jm(qm,i)),t.jwt=i=>t.check(d_(Z8,i)),t.emoji=i=>t.check(Q3(T8,i)),t.guid=i=>t.check(kf(zf,i)),t.uuid=i=>t.check(H3(ya,i)),t.uuidv4=i=>t.check(G3(ya,i)),t.uuidv6=i=>t.check(J3(ya,i)),t.uuidv7=i=>t.check(K3(ya,i)),t.nanoid=i=>t.check(Y3(B8,i)),t.guid=i=>t.check(kf(zf,i)),t.cuid=i=>t.check(X3(R8,i)),t.cuid2=i=>t.check(e_(P8,i)),t.ulid=i=>t.check(t_(N8,i)),t.base64=i=>t.check(l_(M8,i)),t.base64url=i=>t.check(u_(F8,i)),t.xid=i=>t.check(n_(A8,i)),t.ksuid=i=>t.check(r_(O8,i)),t.ipv4=i=>t.check(o_(j8,i)),t.ipv6=i=>t.check(i_($8,i)),t.cidrv4=i=>t.check(a_(L8,i)),t.cidrv6=i=>t.check(s_(D8,i)),t.e164=i=>t.check(c_(U8,i)),t.datetime=i=>t.check(N(i)),t.date=i=>t.check(u8(i)),t.time=i=>t.check(d8(i)),t.duration=i=>t.check(f8(i))});function o(t){return V3(z8,t)}const Ue=j("ZodStringFormat",(t,r)=>{$e.init(t,r),Zm.init(t,r)}),C8=j("ZodEmail",(t,r)=>{Q2.init(t,r),Ue.init(t,r)}),zf=j("ZodGUID",(t,r)=>{J2.init(t,r),Ue.init(t,r)}),ya=j("ZodUUID",(t,r)=>{K2.init(t,r),Ue.init(t,r)}),qm=j("ZodURL",(t,r)=>{Y2.init(t,r),Ue.init(t,r)});function Cf(t){return jm(qm,t)}const T8=j("ZodEmoji",(t,r)=>{X2.init(t,r),Ue.init(t,r)}),B8=j("ZodNanoID",(t,r)=>{e3.init(t,r),Ue.init(t,r)}),R8=j("ZodCUID",(t,r)=>{t3.init(t,r),Ue.init(t,r)}),P8=j("ZodCUID2",(t,r)=>{n3.init(t,r),Ue.init(t,r)}),N8=j("ZodULID",(t,r)=>{r3.init(t,r),Ue.init(t,r)}),A8=j("ZodXID",(t,r)=>{o3.init(t,r),Ue.init(t,r)}),O8=j("ZodKSUID",(t,r)=>{i3.init(t,r),Ue.init(t,r)}),j8=j("ZodIPv4",(t,r)=>{c3.init(t,r),Ue.init(t,r)}),$8=j("ZodIPv6",(t,r)=>{d3.init(t,r),Ue.init(t,r)}),L8=j("ZodCIDRv4",(t,r)=>{p3.init(t,r),Ue.init(t,r)}),D8=j("ZodCIDRv6",(t,r)=>{f3.init(t,r),Ue.init(t,r)}),M8=j("ZodBase64",(t,r)=>{m3.init(t,r),Ue.init(t,r)}),F8=j("ZodBase64URL",(t,r)=>{h3.init(t,r),Ue.init(t,r)}),U8=j("ZodE164",(t,r)=>{g3.init(t,r),Ue.init(t,r)}),Z8=j("ZodJWT",(t,r)=>{_3.init(t,r),Ue.init(t,r)}),Vm=j("ZodNumber",(t,r)=>{Rm.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(s,u,p)=>M_(t,s,u),ei(t,"ZodNumber",{gt(s,u){return this.check(Sa(s,u))},gte(s,u){return this.check(Un(s,u))},min(s,u){return this.check(Un(s,u))},lt(s,u){return this.check(Ia(s,u))},lte(s,u){return this.check(Gr(s,u))},max(s,u){return this.check(Gr(s,u))},int(s){return this.check(Be(s))},safe(s){return this.check(Be(s))},positive(s){return this.check(Sa(0,s))},nonnegative(s){return this.check(Un(0,s))},negative(s){return this.check(Ia(0,s))},nonpositive(s){return this.check(Gr(0,s))},multipleOf(s,u){return this.check(Yl(s,u))},step(s,u){return this.check(Yl(s,u))},finite(){return this}});const i=t._zod.bag;t.minValue=Math.max(i.minimum??Number.NEGATIVE_INFINITY,i.exclusiveMinimum??Number.NEGATIVE_INFINITY)??null,t.maxValue=Math.min(i.maximum??Number.POSITIVE_INFINITY,i.exclusiveMaximum??Number.POSITIVE_INFINITY)??null,t.isInt=(i.format??"").includes("int")||Number.isSafeInteger(i.multipleOf??.5),t.isFinite=!0,t.format=i.format??null});function pr(t){return h_(Vm,t)}const q8=j("ZodNumberFormat",(t,r)=>{w3.init(t,r),Vm.init(t,r)});function Be(t){return g_(q8,t)}const V8=j("ZodBoolean",(t,r)=>{x3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>F_(t,i,s)});function Z(t){return y_(V8,t)}const W8=j("ZodBigInt",(t,r)=>{E3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(s,u,p)=>U_(t,s),t.gte=(s,u)=>t.check(Un(s,u)),t.min=(s,u)=>t.check(Un(s,u)),t.gt=(s,u)=>t.check(Sa(s,u)),t.gte=(s,u)=>t.check(Un(s,u)),t.min=(s,u)=>t.check(Un(s,u)),t.lt=(s,u)=>t.check(Ia(s,u)),t.lte=(s,u)=>t.check(Gr(s,u)),t.max=(s,u)=>t.check(Gr(s,u)),t.positive=s=>t.check(Sa(BigInt(0),s)),t.negative=s=>t.check(Ia(BigInt(0),s)),t.nonpositive=s=>t.check(Gr(BigInt(0),s)),t.nonnegative=s=>t.check(Un(BigInt(0),s)),t.multipleOf=(s,u)=>t.check(Yl(s,u));const i=t._zod.bag;t.minValue=i.minimum??null,t.maxValue=i.maximum??null,t.format=i.format??null}),H8=j("ZodUnknown",(t,r)=>{I3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>q_()});function Jn(){return w_(H8)}const G8=j("ZodNever",(t,r)=>{S3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>Z_(t,i,s)});function $a(t){return x_(G8,t)}const J8=j("ZodArray",(t,r)=>{k3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>J_(t,i,s,u),t.element=r.element,ei(t,"ZodArray",{min(i,s){return this.check(ka(i,s))},nonempty(i){return this.check(ka(1,i))},max(i,s){return this.check($m(i,s))},length(i,s){return this.check(Lm(i,s))},unwrap(){return this.element}})});function P(t,r){return N_(J8,t,r)}const K8=j("ZodObject",(t,r)=>{z3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>K_(t,i,s,u),ze(t,"shape",()=>r.shape),ei(t,"ZodObject",{keyof(){return Kt(Object.keys(this._zod.def.shape))},catchall(i){return this.clone({...this._zod.def,catchall:i})},passthrough(){return this.clone({...this._zod.def,catchall:Jn()})},loose(){return this.clone({...this._zod.def,catchall:Jn()})},strict(){return this.clone({...this._zod.def,catchall:$a()})},strip(){return this.clone({...this._zod.def,catchall:void 0})},extend(i){return q7(this,i)},safeExtend(i){return V7(this,i)},merge(i){return W7(this,i)},pick(i){return U7(this,i)},omit(i){return Z7(this,i)},partial(...i){return H7(Gm,this,i[0])},required(...i){return G7(Jm,this,i[0])}})});function h(t,r){const i={type:"object",shape:t??{},...ie(r)};return new K8(i)}const Wm=j("ZodUnion",(t,r)=>{Am.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>Q_(t,i,s,u),t.options=r.options});function Yn(t,r){return new Wm({type:"union",options:t,...ie(r)})}const Q8=j("ZodDiscriminatedUnion",(t,r)=>{Wm.init(t,r),C3.init(t,r)});function Hm(t,r,i){return new Q8({type:"union",options:r,discriminator:t,...ie(i)})}const Y8=j("ZodIntersection",(t,r)=>{T3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>Y_(t,i,s,u)});function X8(t,r){return new Y8({type:"intersection",left:t,right:r})}const Tf=j("ZodRecord",(t,r)=>{B3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>X_(t,i,s,u),t.keyType=r.keyType,t.valueType=r.valueType});function fe(t,r,i){return!r||!r._zod?new Tf({type:"record",keyType:o(),valueType:t,...ie(r)}):new Tf({type:"record",keyType:t,valueType:r,...ie(i)})}const Xl=j("ZodEnum",(t,r)=>{R3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(s,u,p)=>V_(t,s,u),t.enum=r.entries,t.options=Object.values(r.entries);const i=new Set(Object.keys(r.entries));t.extract=(s,u)=>{const p={};for(const d of s)if(i.has(d))p[d]=r.entries[d];else throw new Error(`Key ${d} not found in enum`);return new Xl({...r,checks:[],...ie(u),entries:p})},t.exclude=(s,u)=>{const p={...r.entries};for(const d of s)if(i.has(d))delete p[d];else throw new Error(`Key ${d} not found in enum`);return new Xl({...r,checks:[],...ie(u),entries:p})}});function Kt(t,r){const i=Array.isArray(t)?Object.fromEntries(t.map(s=>[s,s])):t;return new Xl({type:"enum",entries:i,...ie(r)})}const ew=j("ZodLiteral",(t,r)=>{P3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>W_(t,i,s),t.values=new Set(r.values),Object.defineProperty(t,"value",{get(){if(r.values.length>1)throw new Error("This schema contains multiple valid literal values. Use `.values` instead.");return r.values[0]}})});function x(t,r){return new ew({type:"literal",values:Array.isArray(t)?t:[t],...ie(r)})}const tw=j("ZodTransform",(t,r)=>{N3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>G_(t,i),t._zod.parse=(i,s)=>{if(s.direction==="backward")throw new hm(t.constructor.name);i.addIssue=p=>{if(typeof p=="string")i.issues.push(Yo(p,i.value,r));else{const d=p;d.fatal&&(d.continue=!1),d.code??(d.code="custom"),d.input??(d.input=i.value),d.inst??(d.inst=t),i.issues.push(Yo(d))}};const u=r.transform(i.value,i);return u instanceof Promise?u.then(p=>(i.value=p,i.fallback=!0,i)):(i.value=u,i.fallback=!0,i)}});function nw(t){return new tw({type:"transform",transform:t})}const Gm=j("ZodOptional",(t,r)=>{Om.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>Um(t,i,s,u),t.unwrap=()=>t._zod.def.innerType});function Bf(t){return new Gm({type:"optional",innerType:t})}const rw=j("ZodExactOptional",(t,r)=>{A3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>Um(t,i,s,u),t.unwrap=()=>t._zod.def.innerType});function ow(t){return new rw({type:"optional",innerType:t})}const iw=j("ZodNullable",(t,r)=>{O3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>e8(t,i,s,u),t.unwrap=()=>t._zod.def.innerType});function Rf(t){return new iw({type:"nullable",innerType:t})}const aw=j("ZodDefault",(t,r)=>{j3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>n8(t,i,s,u),t.unwrap=()=>t._zod.def.innerType,t.removeDefault=t.unwrap});function sw(t,r){return new aw({type:"default",innerType:t,get defaultValue(){return typeof r=="function"?r():_m(r)}})}const lw=j("ZodPrefault",(t,r)=>{$3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>r8(t,i,s,u),t.unwrap=()=>t._zod.def.innerType});function uw(t,r){return new lw({type:"prefault",innerType:t,get defaultValue(){return typeof r=="function"?r():_m(r)}})}const Jm=j("ZodNonOptional",(t,r)=>{L3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>t8(t,i,s,u),t.unwrap=()=>t._zod.def.innerType});function cw(t,r){return new Jm({type:"nonoptional",innerType:t,...ie(r)})}const dw=j("ZodCatch",(t,r)=>{D3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>o8(t,i,s,u),t.unwrap=()=>t._zod.def.innerType,t.removeCatch=t.unwrap});function pw(t,r){return new dw({type:"catch",innerType:t,catchValue:typeof r=="function"?r:()=>r})}const fw=j("ZodPipe",(t,r)=>{M3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>i8(t,i,s,u),t.in=r.in,t.out=r.out});function Pf(t,r){return new fw({type:"pipe",in:t,out:r})}const mw=j("ZodReadonly",(t,r)=>{F3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>a8(t,i,s,u),t.unwrap=()=>t._zod.def.innerType});function vw(t){return new mw({type:"readonly",innerType:t})}const hw=j("ZodCustom",(t,r)=>{U3.init(t,r),Le.init(t,r),t._zod.processJSONSchema=(i,s,u)=>H_(t,i)});function gw(t,r={}){return A_(hw,t,r)}function yw(t,r){return O_(t,r)}function w(t){return __(W8,t)}const _w=h({MaxMessageLength:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),SupportsAttachments:Z(),SupportsChildConversations:Z()}),ti=h({account_id:o(),provider:o()});h({dir:o().optional(),name:o().min(1),provider:o().min(1),scope:o().optional()});h({agent:o(),status:o()});const ww=h({agent_id:o(),parent_tool_use_id:o()});h({dir:o().optional(),env:fe(o(),o()).optional(),name:o().optional(),scope:o().optional(),suspended:Z().optional(),tmux_alias:o().optional(),work_dir:o().optional()});h({agent:o(),bytes:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),prompt:o()});h({provider:o().optional(),scope:o().optional(),suspended:Z().optional()});h({provider:o().optional(),scope:o().optional(),suspended:Z().optional()});const xw=h({dir:o().optional(),is_pool:Z().optional(),name:o(),origin:o(),provider:o().optional(),scope:o().optional(),suspended:Z()}),Ew=h({acp_args:P(o()).optional(),acp_command:o().optional(),args:P(o()).nullish(),command:o().optional(),display_name:o().optional(),env:fe(o(),o()).optional(),origin:o(),prompt_flag:o().optional(),prompt_mode:o().optional(),ready_delay_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional()});h({event_cursor:o(),request_id:o(),status:o()});h({event_cursor:o(),request_id:o()});h({assignee:o().optional()});h({reason:o().max(1024).optional()});h({assignee:o().optional(),description:o().optional(),labels:P(o()).nullish(),metadata:fe(o(),o()).optional(),parent:o().optional(),priority:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),rig:o().optional(),title:o().min(1),type:o().optional()});h({assignee:o().optional(),description:o().optional(),labels:P(o()).nullish(),metadata:fe(o(),o()).optional(),parent:o().nullish(),priority:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),remove_labels:P(o()).nullish(),status:o().optional(),title:o().optional(),type:o().optional()});const Iw=Kt(["active","ended"]),yu=h({conversation_id:o(),provider:o(),session_id:o()});h({bootstrap_profile:Kt(["k8s-cell","kubernetes","kubernetes-cell","single-host-compat"]).optional(),dir:o().min(1),provider:o().min(1).optional(),start_command:o().optional()});const _u=h({name:o(),path:o(),request_id:o()});h({agent_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),name:o(),path:o(),provider:o().optional(),rig_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_template:o().optional(),suspended:Z(),uptime_sec:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),version:o().optional()});const Sw=h({error:o().optional(),name:o(),path:o(),phases_completed:P(o()).nullish(),running:Z(),status:o().optional()}),ni=h({name:o(),path:o()});h({suspended:Z().optional()});const wu=h({name:o(),path:o(),request_id:o()}),kw=h({dir:o().optional(),is_pool:Z().optional(),name:o(),provider:o().optional(),scope:o().optional(),suspended:Z()}),bw=h({agents:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),providers:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),rigs:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({agents:P(xw).nullable(),patches:bw,providers:fe(o(),Ew)});const zw=h({agent_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),provider_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),rig_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})}),Cw=h({name:o(),path:o(),prefix:o().optional(),suspended:Z()});h({errors:P(o()).nullable(),valid:Z(),warnings:P(o()).nullable()});h({GroupID:o(),Handle:o(),ID:o(),Metadata:fe(o(),o()),Public:Z(),SessionID:o()});const Tw=Kt(["dm","room","thread"]),Yt=h({account_id:o(),conversation_id:o(),kind:Tw,parent_conversation_id:o().optional(),provider:o(),scope_id:o()});h({items:P(o()).nullish()});h({closed:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),complete:Z(),convoy_id:o(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({items:P(o()).nullish(),rig:o().optional(),title:o().min(1)});const Bw=h({closed:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({items:P(o()).nullish()});const Rw=h({BindingGeneration:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),Conversation:Yt,ID:o(),LastMessageID:o(),LastPublishedAt:N({offset:!0}),Metadata:fe(o(),o()),SchemaVersion:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),SessionID:o(),SourceSessionID:o()}),Pw=h({depends_on_id:o(),issue_id:o(),type:o()}),fr=h({assignee:o().optional(),created_at:N({offset:!0}),dependencies:P(Pw).nullish(),description:o().optional(),ephemeral:Z().optional(),from:o().optional(),id:o(),issue_type:o(),labels:P(o()).nullish(),metadata:fe(o(),o()).optional(),needs:P(o()).nullish(),parent:o().optional(),priority:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).nullish(),ref:o().optional(),status:o(),title:o(),updated_at:N({offset:!0}).optional()});h({children:P(fr).nullable()});const gr=h({bead:fr});h({children:P(fr).nullish(),convoy:fr.optional(),progress:Bw.optional()});const Nw=h({location:o().optional(),message:o().optional(),value:Jn().optional()});h({detail:o().optional(),errors:P(Nw).nullish(),instance:Cf().optional(),status:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),title:o().optional(),type:Cf().optional().default("about:blank")});h({status:o()});h({actor:o().min(1),message:o().optional(),subject:o().optional(),type:o().min(1)});const Aw=h({seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),ts:N({offset:!0}),type:o()}),Ow=h({compression_status:Kt(["pending","complete"]),first_seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),last_seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),path:o()});h({anchor_event:Aw.optional(),archive:Ow.optional(),reason:o().optional(),rotated:Z()});h({account_id:o().min(1),callback_url:o().optional(),capabilities:_w.optional(),name:o().optional(),provider:o().min(1)});h({account_id:o(),name:o(),provider:o(),status:o()});h({account_id:o().min(1),provider:o().min(1)});h({conversation:Yt.optional(),metadata:fe(o(),o()).optional(),session_id:o().min(1)});h({default_handle:o().optional(),metadata:fe(o(),o()).optional(),mode:o().optional(),root_conversation:Yt.optional()});h({conversation:Yt.optional(),idempotency_key:o().optional(),reply_to_message_id:o().optional(),session_id:o().min(1),text:o().optional()});h({group_id:o().min(1),handle:o().min(1)});h({group_id:o().min(1),handle:o().min(1),metadata:fe(o(),o()).optional(),public:Z().optional(),session_id:o().min(1)});h({conversation:Yt.optional(),sequence:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),session_id:o().min(1)});h({conversation:Yt.optional(),session_id:o().min(1)});const Km=h({display_name:o(),id:o(),is_bot:Z()}),Qm=h({mime_type:o(),provider_id:o(),url:o()}),Ym=h({actor:Km,attachments:P(Qm).nullish(),conversation:Yt,dedup_key:o().optional(),explicit_target:o().optional(),provider_message_id:o(),received_at:N({offset:!0}),reply_to_message_id:o().optional(),text:o()});h({account_id:o().optional(),message:Ym.optional(),payload:o().optional(),provider:o().optional()});const jw=h({account_id:o(),name:o(),provider:o()}),$w=h({AllowUntargetedPublication:Z(),Enabled:Z(),MaxPeerTriggeredPublishes:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),MaxTotalPeerDeliveries:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({DefaultHandle:o(),FanoutPolicy:$w,ID:o(),LastAddressedHandle:o(),Metadata:fe(o(),o()),Mode:o(),RootConversation:Yt,SchemaVersion:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({scope_kind:o().optional(),scope_ref:o().optional(),target:o().min(1),vars:fe(o(),o()).optional()});const Xm=h({from:o(),kind:o().optional(),to:o()}),Lw=h({id:o(),kind:o(),scope_ref:o().optional(),title:o()}),Dw=h({edges:P(Xm).nullable(),nodes:P(Lw).nullable()}),ev=h({started_at:o(),status:o(),target:o(),updated_at:o(),workflow_id:o()});h({formula:o(),partial:Z(),partial_errors:P(o()).nullish(),recent_runs:P(ev).nullable(),run_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const Mw=h({assignee:o().optional(),id:o(),kind:o(),labels:P(o()).nullish(),metadata:fe(o(),o()).optional(),title:o(),type:o().optional()}),tv=h({default:Jn().optional(),description:o().optional(),enum:P(o()).nullish(),name:o(),pattern:o().optional(),required:Z().optional(),type:o()});h({deps:P(Xm).nullable(),description:o(),name:o(),preview:Dw,steps:P(Mw).nullable(),var_defs:P(tv).nullable(),version:o()});const Fw=h({description:o(),name:o(),recent_runs:P(ev).nullable(),run_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),var_defs:P(tv).nullable(),version:o()});h({items:P(Fw).nullable(),partial:Z(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const Uw=h({ahead:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),behind:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),branch:o(),changed_files:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),clean:Z()}),xu=h({conversation_id:o(),mode:o(),provider:o()}),Zw=h({Match:o(),TargetSessionID:o(),UpdateCursor:Z()});h({city:o().optional(),status:o(),uptime_sec:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),version:o().optional()});const oo=h({timestamp:o()}),Eu=h({actor:o(),conversation_id:o(),provider:o(),target_session:o()});h({items:P(fr).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({items:P(jw).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const qw=fe(o(),$a());h({partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),unread:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({body:o().optional(),from:o().optional(),subject:o().optional()});h({body:o().optional(),from:o().optional(),rig:o().optional(),subject:o().min(1),to:o().min(1)});const nv=h({body:o(),cc:P(o()).nullish(),created_at:N({offset:!0}),from:o(),id:o(),priority:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),read:Z(),reply_to:o().optional(),rig:o().optional(),subject:o(),thread_id:o().optional(),to:o()}),mt=h({message:nv.optional(),rig:o()});h({items:P(nv).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const rv=h({attached_bead_id:o().optional(),bead_id:o().optional(),detail_available:Z().optional(),id:o(),logical_bead_id:o().optional(),root_bead_id:o().optional(),root_store_ref:o().optional(),run_detail_available:Z().optional(),scope_kind:o(),scope_ref:o(),started_at:o(),status:o(),store_ref:o().optional(),target:o(),title:o(),type:o(),updated_at:o(),workflow_id:o().optional()});h({items:P(rv).nullable(),partial:Z(),partial_errors:P(o()).nullish()});const ve=fe(o(),$a());h({status:o()});h({id:o().optional(),status:o()});const Vw=h({label:o(),value:o()}),Ww=h({due:Z(),last_run:o().optional(),last_run_outcome:o().optional(),name:o(),reason:o(),rig:o().optional(),scoped_name:o()});h({checks:P(Ww).nullable()});h({bead_id:o(),created_at:o(),labels:P(o()).nullable(),output:o(),store_ref:o()});const Hw=h({bead_id:o(),capture_output:Z(),created_at:o(),duration_ms:o().optional(),error:o().optional(),exit_code:o().optional(),has_output:Z(),labels:P(o()).nullable(),name:o(),rig:o().optional(),scoped_name:o(),signal:o().optional(),store_ref:o(),wisp_root_id:o().optional()});h({entries:P(Hw).nullable()});const Gw=h({capture_output:Z(),check:o().optional(),description:o().optional(),enabled:Z(),exec:o().optional(),formula:o().optional(),gate:o().optional(),interval:o().optional(),name:o(),on:o().optional(),pool:o().optional(),rig:o().optional(),schedule:o().optional(),scoped_name:o(),timeout:o().optional(),timeout_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),trigger:o().optional(),type:o()});h({orders:P(Gw).nullable()});h({items:P(rv).nullable(),partial:Z(),partial_errors:P(o()).nullish()});const Iu=h({conversation_id:o(),message_id:o(),provider:o(),session:o()}),Su=h({role:o(),text:o(),timestamp:o().optional()}),Jw=h({name:o(),path:o().optional(),ref:o().optional(),source:o().optional()});h({packs:P(Jw).nullable()});const La=h({has_older_messages:Z(),returned_message_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),total_compactions:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),total_message_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),truncated_before_message:o().optional()}),ov=h({agent:o(),format:o(),pagination:La.optional(),turns:P(Su).nullable()});h({agent_patch:o().optional(),provider_patch:o().optional(),rig_patch:o().optional(),status:o()});h({agent_patch:o().optional(),provider_patch:o().optional(),rig_patch:o().optional(),status:o()});const ku=h({kind:o(),metadata:fe(o(),o()).optional(),options:P(o()).nullish(),prompt:o().optional(),request_id:o()}),Kw=h({Check:o().nullable(),DrainTimeout:o().nullable(),Max:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).nullable(),Min:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).nullable(),OnBoot:o().nullable(),OnDeath:o().nullable()}),Qw=h({AppendFragments:P(o()).nullable(),Attach:Z().nullable(),DefaultSlingFormula:o().nullable(),DependsOn:P(o()).nullable(),Dir:o(),Env:fe(o(),o()),EnvRemove:P(o()).nullable(),HooksInstalled:Z().nullable(),IdleTimeout:o().nullable(),InjectAssignedSkills:Z().nullable(),InjectFragments:P(o()).nullable(),InjectFragmentsAppend:P(o()).nullable(),InstallAgentHooks:P(o()).nullable(),InstallAgentHooksAppend:P(o()).nullable(),Lifecycle:o().nullable(),MCP:P(o()).nullable(),MCPAppend:P(o()).nullable(),MaxActiveSessions:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).nullable(),MaxSessionAge:o().nullable(),MaxSessionAgeJitter:o().nullable(),MinActiveSessions:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).nullable(),MouseMode:o().nullable(),Name:o(),Nudge:o().nullable(),OptionDefaults:fe(o(),o()),OverlayDir:o().nullable(),Pool:Kw,PreStart:P(o()).nullable(),PreStartAppend:P(o()).nullable(),PromptTemplate:o().nullable(),Provider:o().nullable(),ResumeCommand:o().nullable(),ScaleCheck:o().nullable(),Scope:o().nullable(),Session:o().nullable(),SessionLive:P(o()).nullable(),SessionLiveAppend:P(o()).nullable(),SessionSetup:P(o()).nullable(),SessionSetupAppend:P(o()).nullable(),SessionSetupScript:o().nullable(),Skills:P(o()).nullable(),SkillsAppend:P(o()).nullable(),SleepAfterIdle:o().nullable(),StartCommand:o().nullable(),Suspended:Z().nullable(),TmuxAlias:o().nullable(),WakeMode:o().nullable(),WorkDir:o().nullable()});h({items:P(Qw).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const bu=h({host:o(),port:o(),scope_kind:o(),scope_name:o(),source:o(),user:o()}),zu=h({layer:o(),new_id:o(),old_id:o().optional(),scope_root:o(),source:o()});h({acp_args:P(o()).nullish(),acp_command:o().optional(),args:P(o()).nullish(),args_append:P(o()).nullish(),base:o().optional(),command:o().optional(),display_name:o().optional(),env:fe(o(),o()).optional(),name:o().min(1),option_defaults:fe(o(),o()).optional(),options_schema_merge:o().optional(),prompt_flag:o().optional(),prompt_mode:o().optional(),ready_delay_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional()});h({provider:o(),status:o()});const Yw=h({choices:P(Vw).nullable(),default:o(),key:o(),label:o(),type:o()}),Xw=h({ACPArgs:P(o()).nullable(),ACPCommand:o().nullable(),AcceptStartupDialogs:Z().nullable(),Args:P(o()).nullable(),ArgsAppend:P(o()).nullable(),Base:o().nullable(),Command:o().nullable(),Env:fe(o(),o()),EnvRemove:P(o()).nullable(),Name:o(),OptionsSchemaMerge:o().nullable(),PromptFlag:o().nullable(),PromptMode:o().nullable(),ReadyDelayMs:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).nullable(),Replace:Z()});h({items:P(Xw).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({accept_startup_dialogs:Z().optional(),acp_args:P(o()).nullish(),acp_command:o().optional(),args:P(o()).nullish(),command:o().optional(),env:fe(o(),o()).optional(),name:o().optional(),prompt_flag:o().optional(),prompt_mode:o().optional(),ready_delay_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional()});const e5=h({builtin:Z(),city_level:Z(),display_name:o().optional(),effective_defaults:fe(o(),o()).optional(),name:o(),options_schema:P(Yw).nullish()});h({items:P(e5).nullable(),next_cursor:o().optional(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const t5=h({detail:o().optional(),display_name:o(),status:o()});h({providers:fe(o(),t5)});const n5=h({acp_args:P(o()).optional(),acp_command:o().optional(),args:P(o()).nullish(),builtin:Z(),city_level:Z(),command:o().optional(),display_name:o().optional(),env:fe(o(),o()).optional(),name:o(),prompt_flag:o().optional(),prompt_mode:o().optional(),ready_delay_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional()});h({items:P(n5).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const r5=h({acp_args:P(o()).optional(),acp_command:o().optional(),args:P(o()).nullish(),command:o().optional(),display_name:o().optional(),env:fe(o(),o()).optional(),prompt_flag:o().optional(),prompt_mode:o().optional(),ready_delay_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional()});h({acp_args:P(o()).nullish(),acp_command:o().optional(),args:P(o()).nullish(),args_append:P(o()).nullish(),base:o().optional(),command:o().optional(),display_name:o().optional(),env:fe(o(),o()).optional(),option_defaults:fe(o(),o()).optional(),options_schema_merge:o().optional(),prompt_flag:o().optional(),prompt_mode:o().optional(),ready_delay_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional()});const o5=h({Conversation:Yt,Delivered:Z(),FailureKind:o(),MessageID:o(),Metadata:fe(o(),o()),RetryAfter:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})}),i5=h({detail:o().optional(),display_name:o(),kind:o(),name:o(),status:o()});h({items:fe(o(),i5)});const Cu=h({error_code:o(),error_message:o(),operation:Kt(["city.create","city.unregister","session.create","session.message","session.submit"]),request_id:o()});h({action:o(),failed:P(o()).nullish(),killed:P(o()).nullish(),rig:o(),status:o()});h({default_branch:o().optional(),name:o().min(1),path:o().min(1),prefix:o().optional()});h({rig:o(),status:o()});const a5=h({DefaultBranch:o().nullable(),FormulaVars:fe(o(),o()),Name:o(),Path:o().nullable(),Prefix:o().nullable(),Suspended:Z().nullable()});h({items:P(a5).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({default_branch:o().optional(),name:o().optional(),path:o().optional(),prefix:o().optional(),suspended:Z().optional()});const s5=h({agent_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),default_branch:o().optional(),git:Uw.optional(),last_activity:N({offset:!0}).optional(),name:o(),path:o(),prefix:o().optional(),running_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),suspended:Z()});h({items:P(s5).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({default_branch:o().optional(),path:o().optional(),prefix:o().optional(),suspended:Z().optional()});const Tu=h({prior_archive:o(),prior_first_seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),prior_last_seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})}),l5=fe(o(),$a());h({action:o(),service:o(),status:o()});const iv=h({activity:o()});h({messages:P(Jn()).nullable(),status:o().optional()});h({agents:P(ww).nullable()});const Bu=h({BindingGeneration:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),BoundAt:N({offset:!0}),Conversation:Yt,ExpiresAt:N({offset:!0}).nullable(),ID:o(),Metadata:fe(o(),o()),SchemaVersion:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),SessionID:o(),Status:Iw});h({unbound:P(Bu).nullable()});h({items:P(Bu).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({alias:o().optional(),async:Z().optional(),kind:o().optional(),message:o().optional(),name:o().optional(),options:fe(o(),o()).optional(),project_id:o().optional(),session_name:o().optional(),title:o().optional()});const Ru=h({bead_id:o(),bead_status:o().optional(),reason:o().optional(),session_id:o(),template:o().optional()}),u5=h({attached:Z(),last_activity:N({offset:!0}).optional(),name:o()}),c5=h({active_bead:o().optional(),activity:o().optional(),available:Z(),context_pct:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),context_window:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),description:o().optional(),display_name:o().optional(),last_output:o().optional(),model:o().optional(),name:o(),pool:o().optional(),provider:o().optional(),rig:o().optional(),running:Z(),session:u5.optional(),state:o(),suspended:Z(),unavailable_reason:o().optional()});h({items:P(c5).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const yr=h({reason:o().optional(),session_id:o(),template:o().optional()});h({message:o().min(1).regex(/\S/)});const Pu=h({request_id:o(),session_id:o()});h({alias:o().optional(),title:o().min(1).optional()});h({pending:ku.optional(),supported:Z()});h({permission_mode:o().min(1).regex(/\S/)});const av=Jn();h({title:o().min(1)});h({action:o().min(1),metadata:fe(o(),o()).optional(),request_id:o().optional(),text:o().optional()});h({id:o(),status:o()});Yn([iv,ku,oo]);const d5=h({format:o(),id:o(),pagination:La.optional(),provider:o(),template:o(),turns:P(Su).nullable()}),p5=h({format:o(),id:o(),messages:P(av).nullable(),pagination:La.optional(),provider:o(),template:o()}),Nu=h({intent:o(),queued:Z(),request_id:o(),session_id:o()});h({format:o(),id:o(),messages:P(av).nullish(),pagination:La.optional(),provider:o(),template:o(),turns:P(Su).nullish()});h({attached_bead_id:o().optional(),bead:o().optional(),force:Z().optional(),formula:o().optional(),rig:o().optional(),scope_kind:o().optional(),scope_ref:o().optional(),target:o().min(1),title:o().optional(),vars:fe(o(),o()).optional()});h({attached_bead_id:o().optional(),bead:o().optional(),formula:o().optional(),mode:o().optional(),root_bead_id:o().optional(),status:o(),target:o(),warnings:P(o()).nullish(),workflow_id:o().optional()});const f5=h({allow_websockets:Z().optional(),hostname:o().optional(),kind:o().optional(),local_state:o(),mount_path:o(),publication_state:o(),publish_mode:o(),reason:o().optional(),service_name:o(),state:o().optional(),state_root:o(),updated_at:N({offset:!0}),url:o().optional(),visibility:o().optional(),workflow_contract:o().optional()});h({items:P(f5).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const m5=h({quarantined:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),running:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),suspended:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})}),v5=h({draining:Z().optional(),expanded:Z().optional(),group_name:o().optional(),name:o(),qualified_name:o(),running:Z(),scale_label:o().optional(),scope:o(),session_name:o().optional(),suspended:Z()}),h5=h({total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),unread:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})}),g5=h({identity:o(),mode:o(),status:o()}),y5=h({suspended:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})}),_5=h({name:o(),path:o(),suspended:Z()}),w5=h({active:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),suspended:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})}),x5=h({last_gc_at:o().optional(),last_gc_status:o().optional(),live_rows:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),path:o(),ratio_mb_per_row:pr(),size_bytes:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),threshold_mb_per_row:pr(),warning:Z()}),E5=h({in_progress:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),open:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),ready:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({agent_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),agent_details:P(v5).nullish(),agents:m5,mail:h5,name:o(),named_session_details:P(g5).nullish(),partial:Z().optional(),partial_errors:P(o()).nullish(),path:o(),rig_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),rig_details:P(_5).nullish(),rigs:y5,running:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_counts_detail:w5.optional(),store_health:x5.optional(),suspended:Z(),uptime_sec:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),version:o().optional(),work:E5});const Au=h({after_bytes:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),before_bytes:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),duration_s:pr(),snapshot_path:o()}),Ou=h({duration_s:pr(),error_msg:o(),snapshot_path:o().optional(),stage:o()}),I5=h({supports_follow_up:Z(),supports_interrupt_now:Z()}),sv=h({active_bead:o().optional(),activity:o().optional(),agent_kind:o().optional(),alias:o().optional(),attached:Z(),configured_named_session:Z().optional(),context_pct:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),context_window:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),created_at:o(),display_name:o().optional(),id:o(),kind:o().optional(),last_active:o().optional(),last_nudge_delivered_at:o().optional(),last_output:o().optional(),metadata:fe(o(),o()).optional(),model:o().optional(),options:fe(o(),o()).optional(),pool:o().optional(),provider:o(),reason:o().optional(),rig:o().optional(),running:Z(),session_name:o(),state:o(),submission_capabilities:I5.optional(),template:o(),title:o()});h({items:P(sv).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const ju=h({request_id:o(),session:sv}),S5=Kt(["default","follow_up","interrupt_now"]);h({intent:S5.optional(),message:o().min(1).regex(/\S/)});h({items:P(Sw).nullable(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const $u=h({avg60:pr(),consecutive_skips:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),max_consecutive_skips:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),outcome:o(),threshold:pr(),trigger:o().optional()}),Lu=h({client_addr:o().optional(),mode:Kt(["destructive","preserve_sessions","unknown"]),signal:o().optional(),source:Kt(["signal","socket_stop"])}),k5=h({phase:o().optional(),phases_completed:P(o()).nullish(),ready:Z()});h({build_id:o().optional(),cities_running:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),cities_total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),startup:k5.optional(),status:o(),uptime_sec:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),version:o()});const b5=Kt(["inbound","outbound"]),z5=Kt(["live","hydrated"]),Du=h({Actor:Km,Attachments:P(Qm).nullable(),Conversation:Yt,CreatedAt:N({offset:!0}),ExplicitTarget:o(),ID:o(),Kind:b5,Metadata:fe(o(),o()),Provenance:z5,ProviderMessageID:o(),ReplyToMessageID:o(),SchemaVersion:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),Sequence:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),SourceSessionID:o(),Text:o()});h({Binding:Bu,GroupRoute:Zw,Message:Ym,TargetSessionID:o(),TranscriptEntry:Du});h({items:P(Du).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({DeliveryContext:Rw,Receipt:o5,TranscriptEntry:Du});const Mu=h({count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o()}),Fu=h({agent_name:o().optional(),bead_id:o().optional(),cache_creation_tokens:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),cache_read_tokens:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),completion_tokens:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),cost_usd_estimate:pr().optional(),delivered:Z().optional(),duration_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),error:o().optional(),finished_at:N({offset:!0}),latency_ms:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),model:o().optional(),op_id:o(),operation:o(),prompt_sha:o().optional(),prompt_tokens:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),prompt_version:o().optional(),provider:o().optional(),queued:Z().optional(),result:o(),session_id:o().optional(),session_name:o().optional(),started_at:N({offset:!0}),template:o().optional(),transport:o().optional()}),lv=Yn([ti,gr,yu,_u,ni,wu,xu,Eu,mt,ve,Iu,bu,zu,Cu,Tu,ju,Ru,yr,Pu,Nu,Au,Ou,$u,Lu,Mu,Fu]),C5=h({active_attempt:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),attempt_count:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),max_attempts:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional()}),uv=h({assignee:o().optional(),attempt:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),id:o(),kind:o(),logical_bead_id:o().optional(),metadata:fe(o(),o()),scope_ref:o().optional(),status:o(),step_ref:o().optional(),title:o()});h({closed:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),deleted:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),partial:Z().optional(),partial_errors:P(o()).nullish(),workflow_id:o()});const eu=h({from:o(),kind:o().optional(),to:o()});h({beads:P(fr).nullable(),deps:P(eu).nullable(),root:fr});const L=h({attempt_summary:C5.optional(),bead:uv,changed_fields:P(o()).nullable(),event_seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),event_ts:o(),event_type:o(),logical_node_id:o(),requires_resync:Z().optional(),root_bead_id:o(),root_store_ref:o(),scope_kind:o(),scope_ref:o(),type:o(),watch_generation:o(),workflow_id:o(),workflow_seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({actor:o(),message:o().optional(),payload:lv.optional(),run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:o(),workflow:L.optional()});h({actor:o(),city:o(),message:o().optional(),payload:lv.optional(),run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:o(),workflow:L.optional()});const T5=h({actor:o(),message:o().optional(),payload:gr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("bead.closed"),workflow:L.optional()}),B5=h({actor:o(),message:o().optional(),payload:gr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("bead.created"),workflow:L.optional()}),R5=h({actor:o(),message:o().optional(),payload:gr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("bead.updated"),workflow:L.optional()}),P5=h({actor:o(),message:o().optional(),payload:ni,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.created"),workflow:L.optional()}),N5=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.resumed"),workflow:L.optional()}),A5=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.suspended"),workflow:L.optional()}),O5=h({actor:o(),message:o().optional(),payload:ni,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.unregister_requested"),workflow:L.optional()}),j5=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("controller.started"),workflow:L.optional()}),$5=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("controller.stopped"),workflow:L.optional()}),L5=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("convoy.closed"),workflow:L.optional()}),D5=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("convoy.created"),workflow:L.optional()}),M5=h({actor:o(),message:o().optional(),payload:Jn(),run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:o(),workflow:L.optional()}),F5=h({actor:o(),message:o().optional(),payload:Tu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("events.rotated"),workflow:L.optional()}),U5=h({actor:o(),message:o().optional(),payload:ti,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.adapter_added"),workflow:L.optional()}),Z5=h({actor:o(),message:o().optional(),payload:ti,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.adapter_removed"),workflow:L.optional()}),q5=h({actor:o(),message:o().optional(),payload:yu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.bound"),workflow:L.optional()}),V5=h({actor:o(),message:o().optional(),payload:xu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.group_created"),workflow:L.optional()}),W5=h({actor:o(),message:o().optional(),payload:Eu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.inbound"),workflow:L.optional()}),H5=h({actor:o(),message:o().optional(),payload:Iu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.outbound"),workflow:L.optional()}),G5=h({actor:o(),message:o().optional(),payload:Mu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.unbound"),workflow:L.optional()}),J5=h({actor:o(),message:o().optional(),payload:Au,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("gc.store.maintenance.done"),workflow:L.optional()}),K5=h({actor:o(),message:o().optional(),payload:Ou,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("gc.store.maintenance.failed"),workflow:L.optional()}),Q5=h({actor:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.archived"),workflow:L.optional()}),Y5=h({actor:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.deleted"),workflow:L.optional()}),X5=h({actor:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.marked_read"),workflow:L.optional()}),ex=h({actor:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.marked_unread"),workflow:L.optional()}),tx=h({actor:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.read"),workflow:L.optional()}),nx=h({actor:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.replied"),workflow:L.optional()}),rx=h({actor:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.sent"),workflow:L.optional()}),ox=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("order.completed"),workflow:L.optional()}),ix=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("order.failed"),workflow:L.optional()}),ax=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("order.fired"),workflow:L.optional()}),sx=h({actor:o(),message:o().optional(),payload:bu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("pg.credential_resolved"),workflow:L.optional()}),lx=h({actor:o(),message:o().optional(),payload:zu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("project.identity.stamped"),workflow:L.optional()}),ux=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("provider.swapped"),workflow:L.optional()}),cx=h({actor:o(),message:o().optional(),payload:Cu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.failed"),workflow:L.optional()}),dx=h({actor:o(),message:o().optional(),payload:_u,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.city.create"),workflow:L.optional()}),px=h({actor:o(),message:o().optional(),payload:wu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.city.unregister"),workflow:L.optional()}),fx=h({actor:o(),message:o().optional(),payload:ju,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.session.create"),workflow:L.optional()}),mx=h({actor:o(),message:o().optional(),payload:Pu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.session.message"),workflow:L.optional()}),vx=h({actor:o(),message:o().optional(),payload:Nu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.session.submit"),workflow:L.optional()}),hx=h({actor:o(),message:o().optional(),payload:yr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.crashed"),workflow:L.optional()}),gx=h({actor:o(),message:o().optional(),payload:Ru,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.drain_acked_with_assigned_work"),workflow:L.optional()}),yx=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.draining"),workflow:L.optional()}),_x=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.idle_killed"),workflow:L.optional()}),wx=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.max_age_killed"),workflow:L.optional()}),xx=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.quarantined"),workflow:L.optional()}),Ex=h({actor:o(),message:o().optional(),payload:yr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.stopped"),workflow:L.optional()}),Ix=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.stranded"),workflow:L.optional()}),Sx=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.suspended"),workflow:L.optional()}),kx=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.undrained"),workflow:L.optional()}),bx=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.updated"),workflow:L.optional()}),zx=h({actor:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.woke"),workflow:L.optional()}),Cx=h({actor:o(),message:o().optional(),payload:yr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.work_query_failed"),workflow:L.optional()}),Tx=h({actor:o(),message:o().optional(),payload:$u,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("supervisor.fs_pressure.skipped_tick"),workflow:L.optional()}),Bx=h({actor:o(),message:o().optional(),payload:Lu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("supervisor.shutdown_requested"),workflow:L.optional()}),Rx=h({actor:o(),message:o().optional(),payload:Fu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("worker.operation"),workflow:L.optional()}),cv=Hm("type",[T5.extend({type:x("bead.closed")}),B5.extend({type:x("bead.created")}),R5.extend({type:x("bead.updated")}),P5.extend({type:x("city.created")}),N5.extend({type:x("city.resumed")}),A5.extend({type:x("city.suspended")}),O5.extend({type:x("city.unregister_requested")}),j5.extend({type:x("controller.started")}),$5.extend({type:x("controller.stopped")}),L5.extend({type:x("convoy.closed")}),D5.extend({type:x("convoy.created")}),F5.extend({type:x("events.rotated")}),U5.extend({type:x("extmsg.adapter_added")}),Z5.extend({type:x("extmsg.adapter_removed")}),q5.extend({type:x("extmsg.bound")}),V5.extend({type:x("extmsg.group_created")}),W5.extend({type:x("extmsg.inbound")}),H5.extend({type:x("extmsg.outbound")}),G5.extend({type:x("extmsg.unbound")}),J5.extend({type:x("gc.store.maintenance.done")}),K5.extend({type:x("gc.store.maintenance.failed")}),Q5.extend({type:x("mail.archived")}),Y5.extend({type:x("mail.deleted")}),X5.extend({type:x("mail.marked_read")}),ex.extend({type:x("mail.marked_unread")}),tx.extend({type:x("mail.read")}),nx.extend({type:x("mail.replied")}),rx.extend({type:x("mail.sent")}),ox.extend({type:x("order.completed")}),ix.extend({type:x("order.failed")}),ax.extend({type:x("order.fired")}),sx.extend({type:x("pg.credential_resolved")}),lx.extend({type:x("project.identity.stamped")}),ux.extend({type:x("provider.swapped")}),cx.extend({type:x("request.failed")}),dx.extend({type:x("request.result.city.create")}),px.extend({type:x("request.result.city.unregister")}),fx.extend({type:x("request.result.session.create")}),mx.extend({type:x("request.result.session.message")}),vx.extend({type:x("request.result.session.submit")}),hx.extend({type:x("session.crashed")}),gx.extend({type:x("session.drain_acked_with_assigned_work")}),yx.extend({type:x("session.draining")}),_x.extend({type:x("session.idle_killed")}),wx.extend({type:x("session.max_age_killed")}),xx.extend({type:x("session.quarantined")}),Ex.extend({type:x("session.stopped")}),Ix.extend({type:x("session.stranded")}),Sx.extend({type:x("session.suspended")}),kx.extend({type:x("session.undrained")}),bx.extend({type:x("session.updated")}),zx.extend({type:x("session.woke")}),Cx.extend({type:x("session.work_query_failed")}),Tx.extend({type:x("supervisor.fs_pressure.skipped_tick")}),Bx.extend({type:x("supervisor.shutdown_requested")}),Rx.extend({type:x("worker.operation")}),M5.extend({type:x("TypedEventStreamEnvelopeCustom")})]);h({items:P(cv).nullable(),next_cursor:o().optional(),partial:Z().optional(),partial_errors:P(o()).nullish(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});const Px=h({actor:o(),city:o(),message:o().optional(),payload:gr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("bead.closed"),workflow:L.optional()}),Nx=h({actor:o(),city:o(),message:o().optional(),payload:gr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("bead.created"),workflow:L.optional()}),Ax=h({actor:o(),city:o(),message:o().optional(),payload:gr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("bead.updated"),workflow:L.optional()}),Ox=h({actor:o(),city:o(),message:o().optional(),payload:ni,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.created"),workflow:L.optional()}),jx=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.resumed"),workflow:L.optional()}),$x=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.suspended"),workflow:L.optional()}),Lx=h({actor:o(),city:o(),message:o().optional(),payload:ni,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("city.unregister_requested"),workflow:L.optional()}),Dx=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("controller.started"),workflow:L.optional()}),Mx=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("controller.stopped"),workflow:L.optional()}),Fx=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("convoy.closed"),workflow:L.optional()}),Ux=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("convoy.created"),workflow:L.optional()}),Zx=h({actor:o(),city:o(),message:o().optional(),payload:Jn(),run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:o(),workflow:L.optional()}),qx=h({actor:o(),city:o(),message:o().optional(),payload:Tu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("events.rotated"),workflow:L.optional()}),Vx=h({actor:o(),city:o(),message:o().optional(),payload:ti,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.adapter_added"),workflow:L.optional()}),Wx=h({actor:o(),city:o(),message:o().optional(),payload:ti,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.adapter_removed"),workflow:L.optional()}),Hx=h({actor:o(),city:o(),message:o().optional(),payload:yu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.bound"),workflow:L.optional()}),Gx=h({actor:o(),city:o(),message:o().optional(),payload:xu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.group_created"),workflow:L.optional()}),Jx=h({actor:o(),city:o(),message:o().optional(),payload:Eu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.inbound"),workflow:L.optional()}),Kx=h({actor:o(),city:o(),message:o().optional(),payload:Iu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.outbound"),workflow:L.optional()}),Qx=h({actor:o(),city:o(),message:o().optional(),payload:Mu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("extmsg.unbound"),workflow:L.optional()}),Yx=h({actor:o(),city:o(),message:o().optional(),payload:Au,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("gc.store.maintenance.done"),workflow:L.optional()}),Xx=h({actor:o(),city:o(),message:o().optional(),payload:Ou,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("gc.store.maintenance.failed"),workflow:L.optional()}),eE=h({actor:o(),city:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.archived"),workflow:L.optional()}),tE=h({actor:o(),city:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.deleted"),workflow:L.optional()}),nE=h({actor:o(),city:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.marked_read"),workflow:L.optional()}),rE=h({actor:o(),city:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.marked_unread"),workflow:L.optional()}),oE=h({actor:o(),city:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.read"),workflow:L.optional()}),iE=h({actor:o(),city:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.replied"),workflow:L.optional()}),aE=h({actor:o(),city:o(),message:o().optional(),payload:mt,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("mail.sent"),workflow:L.optional()}),sE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("order.completed"),workflow:L.optional()}),lE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("order.failed"),workflow:L.optional()}),uE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("order.fired"),workflow:L.optional()}),cE=h({actor:o(),city:o(),message:o().optional(),payload:bu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("pg.credential_resolved"),workflow:L.optional()}),dE=h({actor:o(),city:o(),message:o().optional(),payload:zu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("project.identity.stamped"),workflow:L.optional()}),pE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("provider.swapped"),workflow:L.optional()}),fE=h({actor:o(),city:o(),message:o().optional(),payload:Cu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.failed"),workflow:L.optional()}),mE=h({actor:o(),city:o(),message:o().optional(),payload:_u,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.city.create"),workflow:L.optional()}),vE=h({actor:o(),city:o(),message:o().optional(),payload:wu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.city.unregister"),workflow:L.optional()}),hE=h({actor:o(),city:o(),message:o().optional(),payload:ju,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.session.create"),workflow:L.optional()}),gE=h({actor:o(),city:o(),message:o().optional(),payload:Pu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.session.message"),workflow:L.optional()}),yE=h({actor:o(),city:o(),message:o().optional(),payload:Nu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("request.result.session.submit"),workflow:L.optional()}),_E=h({actor:o(),city:o(),message:o().optional(),payload:yr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.crashed"),workflow:L.optional()}),wE=h({actor:o(),city:o(),message:o().optional(),payload:Ru,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.drain_acked_with_assigned_work"),workflow:L.optional()}),xE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.draining"),workflow:L.optional()}),EE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.idle_killed"),workflow:L.optional()}),IE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.max_age_killed"),workflow:L.optional()}),SE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.quarantined"),workflow:L.optional()}),kE=h({actor:o(),city:o(),message:o().optional(),payload:yr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.stopped"),workflow:L.optional()}),bE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.stranded"),workflow:L.optional()}),zE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.suspended"),workflow:L.optional()}),CE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.undrained"),workflow:L.optional()}),TE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.updated"),workflow:L.optional()}),BE=h({actor:o(),city:o(),message:o().optional(),payload:ve,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.woke"),workflow:L.optional()}),RE=h({actor:o(),city:o(),message:o().optional(),payload:yr,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("session.work_query_failed"),workflow:L.optional()}),PE=h({actor:o(),city:o(),message:o().optional(),payload:$u,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("supervisor.fs_pressure.skipped_tick"),workflow:L.optional()}),NE=h({actor:o(),city:o(),message:o().optional(),payload:Lu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("supervisor.shutdown_requested"),workflow:L.optional()}),AE=h({actor:o(),city:o(),message:o().optional(),payload:Fu,run_id:o().optional(),seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),session_id:o().optional(),step_id:o().optional(),subject:o().optional(),ts:N({offset:!0}),type:x("worker.operation"),workflow:L.optional()}),dv=Hm("type",[Px.extend({type:x("bead.closed")}),Nx.extend({type:x("bead.created")}),Ax.extend({type:x("bead.updated")}),Ox.extend({type:x("city.created")}),jx.extend({type:x("city.resumed")}),$x.extend({type:x("city.suspended")}),Lx.extend({type:x("city.unregister_requested")}),Dx.extend({type:x("controller.started")}),Mx.extend({type:x("controller.stopped")}),Fx.extend({type:x("convoy.closed")}),Ux.extend({type:x("convoy.created")}),qx.extend({type:x("events.rotated")}),Vx.extend({type:x("extmsg.adapter_added")}),Wx.extend({type:x("extmsg.adapter_removed")}),Hx.extend({type:x("extmsg.bound")}),Gx.extend({type:x("extmsg.group_created")}),Jx.extend({type:x("extmsg.inbound")}),Kx.extend({type:x("extmsg.outbound")}),Qx.extend({type:x("extmsg.unbound")}),Yx.extend({type:x("gc.store.maintenance.done")}),Xx.extend({type:x("gc.store.maintenance.failed")}),eE.extend({type:x("mail.archived")}),tE.extend({type:x("mail.deleted")}),nE.extend({type:x("mail.marked_read")}),rE.extend({type:x("mail.marked_unread")}),oE.extend({type:x("mail.read")}),iE.extend({type:x("mail.replied")}),aE.extend({type:x("mail.sent")}),sE.extend({type:x("order.completed")}),lE.extend({type:x("order.failed")}),uE.extend({type:x("order.fired")}),cE.extend({type:x("pg.credential_resolved")}),dE.extend({type:x("project.identity.stamped")}),pE.extend({type:x("provider.swapped")}),fE.extend({type:x("request.failed")}),mE.extend({type:x("request.result.city.create")}),vE.extend({type:x("request.result.city.unregister")}),hE.extend({type:x("request.result.session.create")}),gE.extend({type:x("request.result.session.message")}),yE.extend({type:x("request.result.session.submit")}),_E.extend({type:x("session.crashed")}),wE.extend({type:x("session.drain_acked_with_assigned_work")}),xE.extend({type:x("session.draining")}),EE.extend({type:x("session.idle_killed")}),IE.extend({type:x("session.max_age_killed")}),SE.extend({type:x("session.quarantined")}),kE.extend({type:x("session.stopped")}),bE.extend({type:x("session.stranded")}),zE.extend({type:x("session.suspended")}),CE.extend({type:x("session.undrained")}),TE.extend({type:x("session.updated")}),BE.extend({type:x("session.woke")}),RE.extend({type:x("session.work_query_failed")}),PE.extend({type:x("supervisor.fs_pressure.skipped_tick")}),NE.extend({type:x("supervisor.shutdown_requested")}),AE.extend({type:x("worker.operation")}),Zx.extend({type:x("TypedTaggedEventStreamEnvelopeCustom")})]);h({event_cursor:o(),items:P(dv).nullable(),total:w().min(BigInt("-9223372036854775808"),{error:"Invalid value: Expected int64 to be >= -9223372036854775808"}).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"})});h({beads:P(uv).nullable(),deps:P(eu).nullable(),logical_edges:P(eu).nullable(),logical_nodes:P(qw).nullable(),partial:Z(),resolved_root_store:o(),root_bead_id:o(),root_store_ref:o(),scope_groups:P(l5).nullable(),scope_kind:o(),scope_ref:o(),snapshot_event_seq:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}).optional(),snapshot_version:w().gte(BigInt(0)).max(BigInt("9223372036854775807"),{error:"Invalid value: Expected int64 to be <= 9223372036854775807"}),stores_scanned:P(o()).nullable(),workflow_id:o()});const OE=h({declared_name:o().optional(),declared_prefix:o().optional(),name:o(),prefix:o().optional(),provider:o().optional(),session_template:o().optional(),suspended:Z()});h({agents:P(kw).nullable(),patches:zw.optional(),providers:fe(o(),r5).optional(),rigs:P(Cw).nullable(),workspace:OE});P(Yn([h({data:oo,event:x("heartbeat"),id:Be().optional(),retry:Be().optional()}),h({data:ov,event:x("turn"),id:Be().optional(),retry:Be().optional()})]));P(Yn([h({data:oo,event:x("heartbeat"),id:Be().optional(),retry:Be().optional()}),h({data:ov,event:x("turn"),id:Be().optional(),retry:Be().optional()})]));fe(o(),o());P(Yn([h({data:cv,event:x("event"),id:Be().optional(),retry:Be().optional()}),h({data:oo,event:x("heartbeat"),id:Be().optional(),retry:Be().optional()})]));P(Yn([h({data:iv,event:x("activity"),id:Be().optional(),retry:Be().optional()}),h({data:oo,event:x("heartbeat"),id:Be().optional(),retry:Be().optional()}),h({data:p5,event:x("message").optional(),id:Be().optional(),retry:Be().optional()}),h({data:ku,event:x("pending"),id:Be().optional(),retry:Be().optional()}),h({data:d5,event:x("turn"),id:Be().optional(),retry:Be().optional()})]));P(Yn([h({data:oo,event:x("heartbeat"),id:o().optional(),retry:Be().optional()}),h({data:dv,event:x("tagged_event"),id:o().optional(),retry:Be().optional()})]));class Wn extends Error{constructor(r,i,s){super(i),this.status=r,this.requestId=s}status;requestId;name="SupervisorApiError"}async function Ie(t,r){let i;try{i=await t}catch(p){throw jE(p)}const{response:s}=i;if(s===void 0)throw new Wn(void 0,tu(i.error),void 0);if(!s.ok||i.error!==void 0)throw new Wn(s.status,tu(i.error,s.statusText),s.headers.get("x-gc-request-id")??void 0);const u=i.data;if(u===void 0)throw new Wn(s.status,r,s.headers.get("x-gc-request-id")??void 0);return u}function jE(t){return t instanceof Wn?t:new Wn(void 0,tu(t),void 0)}function tu(t,r="gc supervisor request failed"){if(typeof t=="string"&&t.trim().length>0)return t.trim();if(t instanceof Error&&t.message.trim().length>0)return t.message.trim();if($E(t))for(const i of["error","message","detail"]){const s=t[i];if(typeof s=="string"&&s.trim().length>0)return s.trim()}return r}function $E(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)}const LE="";function DE(){const t=globalThis.location?.origin;return typeof t=="string"&&t.length>0&&t!=="null"?t:LE}function ME(t){if(!t.startsWith("/"))return t;const r=globalThis.location?.origin;return typeof r!="string"||r.length===0||r==="null"?t:new URL(t,r).toString().replace(/\/$/,"")}function Nf(t,r,i){const s=t.replace(/\/$/,""),u=new URLSearchParams(i).toString(),p=u.length>0?`${r}?${u}`:r;return s===""?p:s.startsWith("/")?`${s}${p}`:new URL(p,`${s}/`).toString()}const FE=6e4,Rt={"X-GC-Request":"dashboard"};let Af=null;const Of=new Map;function pv(t={}){const r=t.baseUrl??DE(),s={baseUrl:ME(r),headers:{Accept:"application/json"},responseStyle:"fields",throwOnError:!1},u=t.client??vm({...s,fetch:ZE(t.fetch??globalThis.fetch,fv(t.timeoutMs))});return{baseUrl:r,health(){return Ie(i7({client:u}),"gc supervisor health response was empty")},cityHealth(p){return Ie(w7({client:u,path:{cityName:p}}),"gc supervisor city health response was empty")},cityStatus(p){return Ie(A7({client:u,path:{cityName:p}}),"gc supervisor status response was empty")},listCities(){return Ie(a7({client:u}),"gc supervisor cities response was empty")},listAgents(p){return Ie(d7({client:u,path:{cityName:p}}),"gc supervisor agents response was empty")},listRigs(p){return Ie(C7({client:u,path:{cityName:p}}),"gc supervisor rigs response was empty")},listBeads(p,d){return Ie(v7({client:u,path:{cityName:p},...d===void 0?{}:{query:d}}),"gc supervisor beads response was empty")},listEvents(p,d){return Ie(g7({client:u,path:{cityName:p},...d===void 0?{}:{query:d}}),"gc supervisor events response was empty")},getBead(p,d){return Ie(p7({client:u,path:{cityName:p,id:d}}),"gc supervisor bead response was empty")},createBead(p,d){return Ie(h7({client:u,path:{cityName:p},headers:Rt,body:d}),"gc supervisor bead create response was empty")},updateBead(p,d,m){return Ie(f7({client:u,path:{cityName:p,id:d},headers:Rt,body:m}),"gc supervisor bead update response was empty")},closeBead(p,d,m){return Ie(m7({client:u,path:{cityName:p,id:d},headers:Rt,...m===void 0?{}:{body:m}}),"gc supervisor bead close response was empty")},nudgeAgent(p,d){const m=jf(d);return"dir"in m?Ie(c7({client:u,path:{cityName:p,dir:m.dir,base:m.base,action:"nudge"},headers:Rt}),"gc supervisor agent nudge response was empty"):Ie(l7({client:u,path:{cityName:p,base:m.base,action:"nudge"},headers:Rt}),"gc supervisor agent nudge response was empty")},agentPrime(p,d){const m=jf(d);return"dir"in m?Ie(u7({client:u,path:{cityName:p,dir:m.dir,base:m.base}}),"gc supervisor agent prime response was empty"):Ie(s7({client:u,path:{cityName:p,base:m.base}}),"gc supervisor agent prime response was empty")},sling(p,d){return Ie(N7({client:u,path:{cityName:p},headers:Rt,body:d}),"gc supervisor sling response was empty")},listMail(p,d){return Ie(x7({client:u,path:{cityName:p},...d===void 0?{}:{query:d}}),"gc supervisor mail response was empty")},formulaFeed(p,d){return Ie(y7({client:u,path:{cityName:p},...d===void 0?{}:{query:d}}),"gc supervisor formula feed response was empty")},sendMail(p,d){return Ie(E7({client:u,path:{cityName:p},headers:Rt,body:d}),"gc supervisor mail send response was empty")},mailThread(p,d){return Ie(I7({client:u,path:{cityName:p,id:d}}),"gc supervisor mail thread response was empty")},markMailRead(p,d,m){return Ie(b7({client:u,path:{cityName:p,id:d},headers:Rt,...m===void 0?{}:{query:m}}),"gc supervisor mail mark-read response was empty")},markMailUnread(p,d,m){return Ie(k7({client:u,path:{cityName:p,id:d},headers:Rt,...m===void 0?{}:{query:m}}),"gc supervisor mail mark-unread response was empty")},archiveMail(p,d,m){return Ie(S7({client:u,path:{cityName:p,id:d},headers:Rt,...m===void 0?{}:{query:m}}),"gc supervisor mail archive response was empty")},replyMail(p,d,m,g){return Ie(z7({client:u,path:{cityName:p,id:d},headers:Rt,body:m,...g===void 0?{}:{query:g}}),"gc supervisor mail reply response was empty")},cityEventStreamUrl(p,d){return Nf(r,`/v0/city/${encodeURIComponent(p)}/events/stream`,d===void 0?void 0:{after_seq:d})},sessionStreamUrl(p,d,m){return Nf(r,`/v0/city/${encodeURIComponent(p)}/session/${encodeURIComponent(d)}/stream`,m===void 0?void 0:{after:m})},listSessions(p){return Ie(P7({client:u,path:{cityName:p}}),"gc supervisor sessions response was empty")},sessionPending(p,d){return Ie(T7({client:u,path:{cityName:p,id:d}}),"gc supervisor session pending response was empty")},respondSession(p,d,m){return Ie(B7({client:u,path:{cityName:p,id:d},headers:Rt,body:m}),"gc supervisor session respond response was empty")},sessionTranscript(p,d){return Ie(R7({client:u,path:{cityName:p,id:d},query:{format:"conversation"}}),"gc supervisor transcript response was empty")},workflowRun(p,d,m){return Ie(O7({client:u,path:{cityName:p,workflow_id:d},...m===void 0?{}:{query:m}}),"gc supervisor workflow response was empty")},formulaDetail(p,d,m){return Ie(_7({client:u,path:{cityName:p,name:d},query:m}),"gc supervisor formula detail response was empty")},mutationHeaders(){return{...Rt}}}}function ot(){return Af??=pv(),Af}function UE(t){const r=fv(t),i=Of.get(r);if(i!==void 0)return i;const s=pv({timeoutMs:r});return Of.set(r,s),s}function jf(t){const r=t.trim().split("/");if(r.length===1){const i=r[0];if(i!==void 0&&i!=="")return{base:i}}if(r.length===2){const i=r[0],s=r[1];if(i!==void 0&&i!==""&&s!==void 0&&s!=="")return{dir:i,base:s}}throw new Error(`invalid agent alias: ${t}`)}function fv(t){return typeof t=="number"&&Number.isFinite(t)&&t>0?t:FE}function ZE(t,r){return async(i,s)=>{const u=new AbortController,p=new Wn(void 0,`gc supervisor request timed out after ${r}ms`,void 0),d=qE(i,s);d?.aborted&&u.abort(d.reason);const m=()=>u.abort(d?.reason);d?.addEventListener("abort",m,{once:!0});let g;const y=new Promise((T,A)=>{g=setTimeout(()=>{u.abort(p),A(p)},r)}),E=new Request(i,{...s,signal:u.signal}),S=t(E);try{return await Promise.race([S,y])}finally{g!==void 0&&clearTimeout(g),d?.removeEventListener("abort",m)}}}function qE(t,r){return r?.signal!==void 0?r.signal:t instanceof Request?t.signal:null}async function VE(t,r){const i=xn("list agent pending interactions"),s=WE(r),u=t.flatMap(d=>{const m=d.session?.name;if(m===void 0)return[];const g=s.get(m);return g===void 0?[]:[{agentName:d.name,sessionId:g,sessionName:m}]});return(await Promise.all(u.map(async d=>{const m=await ot().sessionPending(i,d.sessionId);return m.pending===void 0?null:{...d,pending:m.pending}}))).filter(d=>d!==null)}async function j6(t,r){const i=xn("respond to agent pending interaction");return ot().respondSession(i,t,r)}function $6(t){return`gc agent attach ${HE(t)}`}function WE(t){const r=new Map;for(const i of t)i.session_name!==void 0&&r.set(i.session_name,i.id);return r}function HE(t){return/^[A-Za-z0-9_./:-]+$/.test(t)?t:`'${t.replaceAll("'","'\\''")}'`}const GE=1e3,JE=200,KE=1e3,QE=new Set(["feature","bug","task","epic","chore","decision"]);async function YE(t={}){const r=xn("list supervisor beads"),i=t.limit??GE,s=t.rigFilter?.trim()??"",u=t.includeClosed??!1,p=t.includeBookkeeping??!1,d={limit:i,...u?{all:!0}:{},...s.length===0?{}:{rig:s}},m=await ot().listBeads(r,d),g=vv(m.items??[]),y=u?g:g.filter(T=>T.status!=="closed"),E=p?y:y.filter(XE),S=mv(m.total);return{items:E,total:E.length,...S===void 0?{}:{upstream_total:S},upstream_fetched:g.length,fetch_limit:i}}async function L6(t,r={}){const i=xn("list supervisor assigned beads"),s=t4(t),u=r.limit??JE,p=r.includeClosed??!1;if(s.length===0)return{items:[],total:0,upstream_fetched:0,fetch_limit:u};const d=await Promise.all(s.map(y=>ot().listBeads(i,{assignee:y,limit:u,...p?{all:!0}:{}}))),m=vv(d.flatMap(y=>y.items??[])),g=e4(d);return{items:m,total:m.length,...g===void 0?{}:{upstream_total:g},upstream_fetched:m.length,fetch_limit:u}}async function D6(t){const r=xn("fetch supervisor bead");try{return await ot().getBead(r,t)}catch(i){if(!(i instanceof Wn)||i.status!==404)throw i;const u=((await ot().listBeads(r,{limit:KE})).items??[]).find(p=>p.id===t);if(u!==void 0)return u;throw i}}function XE(t){return!(!QE.has(t.issue_type)||Array.isArray(t.labels)&&t.labels.some(r=>r.startsWith("gc:")))}function mv(t){if(typeof t=="number")return t;if(typeof t=="bigint")return Number(t)}function e4(t){let r=0;for(const i of t){const s=mv(i.total);if(s===void 0)return;r+=s}return r}function vv(t){const r=new Set,i=[];for(const s of t)r.has(s.id)||(r.add(s.id),i.push(s));return i}function t4(t){const r=new Set,i=[];for(const s of t){const u=s.trim();u.length===0||r.has(u)||(r.add(u),i.push(u))}return i}const M6=[100,500,1e3],Uu=100,F6=["24h","7d","all"],n4="all",r4={"24h":1440*60*1e3,"7d":10080*60*1e3};async function Zu(t,r,i,s=Uu,u=n4,p=Date.now()){const d=xn("list supervisor mail"),m=await ot().listMail(d,{limit:s}),g=m.items??[],y=i4(o4(g,t,r,i),u,p);return y.sort(l4),{...m,items:y,total:y.length,upstream_total:g.length,upstream_fetched:g.length,fetch_limit:s}}async function U6(t,r,i,s=Uu){const u=xn("fetch supervisor mail thread");try{const p=await ot().mailThread(u,t);return $f(p)}catch(p){if(!(p instanceof Wn)||p.status!==404)throw p;const d=await Zu("all",r,i,s),m=d.items.filter(g=>g.thread_id===t);return $f({...d,items:m,total:m.length})}}function $f(t){const r=s4(t.items??[]).sort(u4);return{...t,items:r,total:r.length}}function o4(t,r,i,s){const u=a4(i,s);return r==="all"?[...t]:r==="inbox"?t.filter(p=>p.to.toLowerCase()===u):t.filter(p=>p.from.toLowerCase()===u)}function i4(t,r,i){if(r==="all")return[...t];const s=i-r4[r];return t.filter(u=>{const p=Date.parse(u.created_at);return Number.isFinite(p)&&p>=s})}function a4(t,r){const i=t.toLowerCase();return i===r.operatorAlias.toLowerCase()?r.operatorWireAlias:i}function s4(t){const r=new Set,i=[];for(const s of t)r.has(s.id)||(r.add(s.id),i.push(s));return i}function l4(t,r){return r.created_at.localeCompare(t.created_at)}function u4(t,r){return t.created_at.localeCompare(r.created_at)}function hv(t,r){if(t===void 0||t.length===0)return null;const i=Date.parse(t);if(!Number.isFinite(i))return null;const s=r-i;return s>=0?s:null}function gv(t){const r=Math.max(1,Math.round(t/36e5));return r<48?`${r}h`:`${Math.round(r/24)}d`}const c4=1440*60*1e3,d4=4320*60*1e3;function p4(t,r){const i=[];for(const s of t.escalations){const u=f4(s);u!==null&&i.push(u)}for(const s of t.beads){const u=m4(s,r);u!==null&&i.push(u)}return i}function f4(t){return t.status==="closed"?null:{beadId:t.id,reason:"escalated",severity:"attention",summary:`${t.title} — escalation raised`,updatedAt:t.updated_at??t.created_at}}function m4(t,r){if(t.status!=="open"||v4(t))return null;const i=hv(t.created_at,r);if(i===null||i=d4;return{beadId:t.id,reason:"ready-unclaimed",severity:s?"attention":"watch",summary:`${t.title} opened ${gv(i)} ago`,updatedAt:t.created_at}}function v4(t){return t.assignee!==void 0&&t.assignee.trim().length>0}function Lf(t,r){const i=`/runs/${encodeURIComponent(t)}`;if(r.status!=="available")return i;const s=new URLSearchParams;return s.set("scope_kind",r.kind),s.set("scope_ref",r.ref),`${i}?${s.toString()}`}const h4={"awaiting-input":"awaiting input",errored:"errored","rate-limited":"rate limited",stalled:"stalled"},g4={respond:"Respond to its prompt.",reset:"Reset the agent.",nudge:"Nudge it to resume."},y4={"awaiting-input":"stuck",errored:"stuck","rate-limited":"warn",stalled:"warn"};function _4(t){return h4[t]}function Z6(t){return g4[t]}function q6(t){return y4[t]}const w4=new Set(["gc.store.maintenance.failed","order.failed","request.failed","session.crashed","session.stranded","session.work_query_failed","supervisor.shutdown_requested"]),x4=new Set(["events.rotated","session.quarantined","session.suspended","supervisor.fs_pressure.skipped_tick"]);function E4(t){return w4.has(t.type)?"attention":x4.has(t.type)?"watch":"event"}function I4(t){return t.message??t.subject??t.type}const S4=1440*60*1e3,k4=30,b4=2e9,z4=1e9,C4=1e9,T4=512e6,B4="gc:escalation",R4="decision.decide";function P4(t={}){return Xo.map(r=>N4(r,t))}function N4(t,r){switch(t){case"activity":return D4(r.activity);case"agents":return j4(r.agents);case"beads":return $4(r.beads);case"health":return A4(r.health);case"mail":return L4(r.mail);case"runs":return O4(r.runs)}}function A4(t){return{id:"health:derived",domain:"health",getItems:()=>Q4(t)}}function O4(t){return{id:"runs:derived",domain:"runs",getItems:()=>M4(t)}}function j4(t){return{id:"agents:derived",domain:"agents",getItems:()=>F4(t)}}function $4(t){return{id:"beads:derived",domain:"beads",getItems:()=>U4(t)}}function L4(t){return{id:"mail:derived",domain:"mail",getItems:()=>W4(t)}}function D4(t){return{id:"activity:derived",domain:"activity",getItems:()=>G4(t)}}function M4(t){const r=[];if(t===void 0)return r;const i={provenance:t.provenance,fetchedAt:t.fetchedAt};if(t.error!==void 0&&t.error.length>0)return r.push(It("runs",{id:"runs:unavailable",title:"Run data unavailable",summary:t.error,href:"/runs"})),r;const s=t.summary;if(s===void 0)return r;s.lanesPartial===!0&&r.push(Ho("runs",{id:"runs:partial",title:"Run list incomplete",href:"/runs"},i));for(const u of[...s.lanes,...s.blockedLanes])u.health.status!=="available"&&r.push(Ho("runs",{id:`runs:${u.id}:health-unavailable`,title:`${u.title} health unavailable`,summary:u.health.error,href:Lf(u.id,u.scope)},i));for(const u of oy(s.blockedLanes))r.push(It("runs",{id:`runs:${u.id}:blocked`,title:`${u.title} blocked`,summary:u.reason,href:Lf(u.id,u.scope)}));return r}function F4(t){const r=[];if(t===void 0)return r;if(t.error!==void 0&&t.error.length>0)return r.push(Ho("agents",{id:"agents:unavailable",title:"Agent data unavailable",summary:t.error,href:"/agents"})),r;t.partial===!0&&r.push(Ho("agents",{id:"agents:partial",title:"Agent list incomplete",href:"/agents"})),t.pendingError!==void 0&&t.pendingError.length>0&&r.push(Ho("agents",{id:"agents:pending-unavailable",title:"Agent pending state unavailable",summary:t.pendingError,href:"/agents"}));const i=(t.pendingInteractions??[]).map(s=>({agentName:s.agentName,...s.pending.prompt===void 0?{}:{prompt:s.pending.prompt}}));for(const s of X0(t.items??[],i))r.push(It("agents",{id:`agents:${s.name}:needs-you`,title:`${s.name} ${_4(s.reason)}`,summary:s.detail,href:`/agents/${encodeURIComponent(s.name)}`}));return r}function U4(t){const r=[];if(t===void 0)return r;t.error!==void 0&&t.error.length>0&&r.push(It("beads",{id:"beads:unavailable",title:"Bead data unavailable",summary:t.error,href:"/beads"})),t.partial===!0&&r.push(qn("beads",{id:"beads:partial",title:"Bead list incomplete",href:"/beads"})),t.decisionsError!==void 0&&t.decisionsError.length>0&&r.push(It("beads",{id:"beads:decisions-unavailable",title:"Decision queue unavailable",summary:t.decisionsError,href:"/beads"})),t.escalationsError!==void 0&&t.escalationsError.length>0&&r.push(It("beads",{id:"beads:escalations-unavailable",title:"Escalation queue unavailable",summary:t.escalationsError,href:"/beads"}));for(const u of t.decisions??[])r.push(V4(u));const i=t.nowMs??Date.now(),s=(t.items??[]).filter(u=>!q4(u,t.decisionLabel));for(const u of p4({beads:s,escalations:t.escalations??[]},i)){const p=u.severity==="attention"?It:qn;r.push(p("beads",{id:`beads:${u.beadId}:${u.reason}`,title:`${u.beadId} ${Z4(u.reason)}`,summary:u.summary,href:yv(u.beadId),updatedAt:u.updatedAt}))}return r}function Z4(t){return t==="escalated"?"escalated":"unclaimed"}function yv(t){const r=new URLSearchParams;return r.set("bead",t),`/beads?${r.toString()}`}function q4(t,r){return(t.labels??[]).includes(r)}function V4(t){const r=t.metadata?.[R4];return It("beads",{id:`beads:${t.id}:mayor-decision`,title:t.title,href:yv(t.id),updatedAt:t.updated_at??t.created_at,...r!==void 0&&r.trim().length>0?{summary:r}:{}})}function W4(t){const r=[];if(t===void 0)return r;t.error!==void 0&&t.error.length>0&&r.push(It("mail",{id:"mail:unavailable",title:"Mail data unavailable",summary:t.error,href:"/mail"})),t.partial===!0&&r.push(qn("mail",{id:"mail:partial",title:"Mail list incomplete",href:"/mail"}));const i=t.nowMs??Date.now();for(const s of py(t.items??[])){const u=hv(s.created_at,i),p=u!==null&&u>=S4;r.push(It("mail",{id:`mail:${s.id}:${p?"unread-stale":"unread"}`,title:s.subject,summary:p?`from ${s.from}, unread for ${gv(u)}`:`from ${s.from}`,href:H4(s.id),updatedAt:s.created_at}))}return r}function H4(t){const r=new URLSearchParams;return r.set("message",t),`/mail?${r.toString()}`}function G4(t){const r=[];if(t===void 0)return r;t.deploysError!==void 0&&t.deploysError.length>0&&r.push(It("activity",{id:"activity:deploys-unavailable",title:"Deploy data unavailable",summary:t.deploysError,href:"/activity"})),t.eventsDegraded!==void 0&&t.eventsDegraded.length>0&&r.push(qn("activity",{id:"activity:events-degraded",title:"Event stream degraded",summary:t.eventsDegraded,href:"/activity"})),t.eventsError!==void 0&&t.eventsError.length>0&&r.push(qn("activity",{id:"activity:events-unavailable",title:"Event history unavailable",summary:t.eventsError,href:"/activity"})),t.eventsPartial===!0&&r.push(qn("activity",{id:"activity:events-partial",title:"Event history incomplete",href:"/activity"})),J4(r,t.events??[]);const i=t.deploys;if(i===void 0)return r;i.failed_marker&&r.push(It("activity",{id:"activity:failed-marker",title:"Deploy failed marker present",href:"/activity"}));for(const s of i.items)s.status==="failed"?r.push(It("activity",{id:`activity:deploy:${s.at}:failed`,title:"Deploy failed",summary:s.detail,href:"/activity",updatedAt:s.at})):s.status==="in-progress"&&r.push(qn("activity",{id:`activity:deploy:${s.at}:in-progress`,title:"Deploy in progress",summary:s.detail,href:"/activity",updatedAt:s.at}));return r}function J4(t,r){for(const i of r){const s=E4(i);if(s==="event")continue;const u=s==="attention"?It:qn;t.push(u("activity",{id:`activity:event:${String(i.seq)}:${i.type}`,title:i.type,summary:I4(i),href:K4(i),updatedAt:i.ts}))}}function K4(t){return`/activity?${new URLSearchParams({mode:"events",type:t.type}).toString()}`}function Q4(t){const r=[];return t===void 0||(t.dashboardError!==void 0&&t.dashboardError.length>0&&r.push(Hn({id:"health:dashboard-health-unavailable",title:"Dashboard health unavailable",summary:t.dashboardError})),t.supervisor!==void 0&&Y4(r,t.supervisor),t.system!==void 0&&(X4(r,t.system),eI(r,t.system)),t.trend!==void 0&&!t.trend.available&&r.push(mr({id:"health:dolt-noms-unavailable",title:"Dolt-noms trend unavailable",summary:t.trend.reason}))),r}function Y4(t,r){if(r.status==="unavailable"){t.push(Hn({id:"health:supervisor-unreachable",title:"Supervisor unreachable",summary:r.error}));return}const i=r.data;i.status!=="ok"&&t.push(Hn({id:"health:supervisor-not-ok",title:`Supervisor ${i.status}`})),i.city===void 0&&t.push(mr({id:"health:supervisor-city-missing",title:"Supervisor city missing",summary:"city was absent from generated supervisor health"})),i.version===void 0&&t.push(mr({id:"health:supervisor-version-missing",title:"Supervisor version missing",summary:"version was absent from generated supervisor health"}))}function X4(t,r){const i=r.admin;i.uptime_sec=b4?t.push(Hn({id:"health:dashboard-process-rss-high",title:"Dashboard RSS high",summary:_a(i.rss_bytes)})):i.rss_bytes>=z4&&t.push(mr({id:"health:dashboard-process-rss-elevated",title:"Dashboard RSS elevated",summary:_a(i.rss_bytes)})),i.heap_used_bytes>=C4?t.push(Hn({id:"health:dashboard-process-heap-high",title:"Dashboard heap high",summary:_a(i.heap_used_bytes)})):i.heap_used_bytes>=T4&&t.push(mr({id:"health:dashboard-process-heap-elevated",title:"Dashboard heap elevated",summary:_a(i.heap_used_bytes)}))}function eI(t,r){const i=Df(r.host.free_mem_bytes,r.host.total_mem_bytes);i!==null&&i<.05?t.push(Hn({id:"health:memory-critical",title:"Host memory critical",summary:`${Math.round(i*100)}% free`})):i!==null&&i<.1&&t.push(mr({id:"health:memory-low",title:"Host memory low",summary:`${Math.round(i*100)}% free`}));const s=Df(r.host.load_avg_1,r.host.cpu_count);s!==null&&s>1.5?t.push(Hn({id:"health:load-high",title:"Host load high",summary:`${r.host.load_avg_1.toFixed(2)} load across ${r.host.cpu_count} CPUs`})):s!==null&&s>1&&t.push(mr({id:"health:load-elevated",title:"Host load elevated",summary:`${r.host.load_avg_1.toFixed(2)} load across ${r.host.cpu_count} CPUs`}))}function _a(t){return t>=1e9?`${(t/1e9).toFixed(1)} GB`:t>=1e6?`${Math.round(t/1e6)} MB`:t>=1e3?`${Math.round(t/1e3)} KB`:`${t} B`}function Df(t,r){return r<=0?null:t/r}function Hn(t){return{domain:"health",severity:"attention",href:"/health",current:!0,actionable:!0,...t}}function It(t,r){return{domain:t,severity:"attention",current:!0,actionable:!0,...r}}function qn(t,r){return{domain:t,severity:"watch",current:!0,actionable:!1,...r}}function Ho(t,r,i){return{domain:t,severity:"unavailable",current:!0,actionable:!1,...r,...i?.provenance===void 0?{}:{provenance:i.provenance},...i?.fetchedAt===void 0?{}:{fetchedAt:i.fetchedAt}}}function mr(t){return{domain:"health",severity:"watch",href:"/health",current:!0,actionable:!1,...t}}const tI=1e3,nI=100,rI="24h",oI=2500;function iI(t,r){const i=Ba(),s=i??"no-city",{decisionLabel:u,operatorWireAlias:p}=t,d=b.useMemo(()=>aI(r),[r]),m=mn(`attention:agents:${s}`,()=>sI(i)),g=mn(`attention:beads:${s}:${u}`,()=>lI(i,u)),y=mn(`attention:mail:${s}:${p}`,()=>dI(i,t)),E=mn(`attention:activity:${s}`,()=>pI(i)),S=mn(`attention:health:${s}`,()=>fI(i));return b.useMemo(()=>P4(mI({activity:E.data,agents:m.data,beads:g.data,health:S.data,mail:y.data,runs:d})),[E.data,m.data,g.data,S.data,y.data,d])}function aI(t){if(t!==void 0)return t.status==="error"?{error:t.error,provenance:"error"}:{summary:t.data,provenance:t.status,fetchedAt:t.fetchedAt}}async function sI(t){if(t===null)return{};try{const r=await ot().listAgents(t),i={items:r.items??[],partial:r.partial===!0};try{const s=await ot().listSessions(t);i.pendingInteractions=await VE(r.items??[],s.items??[])}catch(s){i.pendingError=Jt(s,"agent pending state unavailable")}return i}catch(r){return{error:Jt(r,"agent list unavailable")}}}async function lI(t,r){if(t===null)return{decisionLabel:r};const[i,s,u]=await Promise.allSettled([YE({limit:tI}),uI(t,r),cI(t)]),p={nowMs:Date.now(),decisionLabel:r};return i.status==="fulfilled"?(p.items=i.value.items,p.partial=i.value.partial===!0):p.error=Jt(i.reason,"bead list unavailable"),s.status==="fulfilled"?p.decisions=s.value.items??[]:p.decisionsError=Jt(s.reason,"decision queue unavailable"),u.status==="fulfilled"?p.escalations=u.value.items??[]:p.escalationsError=Jt(u.reason,"escalation queue unavailable"),p}async function uI(t,r){return ot().listBeads(t,{label:r,status:"open"})}async function cI(t){return ot().listBeads(t,{label:B4,status:"open"})}async function dI(t,r){if(t===null)return{};try{const i=await Zu("inbox",r.operatorAlias,r,Uu);return{items:i.items??[],nowMs:Date.now(),partial:i.partial===!0}}catch(i){return{error:Jt(i,"mail list unavailable")}}}async function pI(t){const[r,i]=await Promise.allSettled([Yr.listBuilds(),t===null?Promise.resolve(null):ot().listEvents(t,{limit:nI,since:rI})]),s={};return r.status==="fulfilled"?s.deploys=r.value:s.deploysError=Jt(r.reason,"deploy activity unavailable"),i.status==="fulfilled"?i.value!==null&&(s.events=i.value.items??[],s.eventsPartial=i.value.partial===!0,i.value.partial_errors!==null&&i.value.partial_errors!==void 0&&(s.eventsDegraded=i.value.partial_errors.join("; "))):s.eventsError=Jt(i.reason,"event history unavailable"),s}async function fI(t){if(t===null)return{};const[r,i,s]=await Promise.allSettled([Yr.systemHealth(),UE(oI).cityHealth(t),Yr.doltTrend()]),u={},p=[];return r.status==="fulfilled"?u.system=r.value:p.push(Jt(r.reason,"dashboard health unavailable")),i.status==="fulfilled"?u.supervisor={status:"available",data:i.value}:u.supervisor={status:"unavailable",error:Jt(i.reason,"supervisor health unavailable")},s.status==="fulfilled"?u.trend=s.value:p.push(Jt(s.reason,"dolt-noms trend unavailable")),p.length>0&&(u.dashboardError=p.join("; ")),u}function mI(t){const r={};for(const[i,s]of Object.entries(t))s!==void 0&&(r[i]=s);return r}async function Jr(t){const r={Accept:"application/json","Content-Type":"application/json","X-GC-Request":"dashboard"};try{const i=await fetch("/api/client-errors",{method:"POST",headers:r,credentials:"same-origin",keepalive:!0,body:JSON.stringify(t)});return i.ok?{status:"reported"}:{status:"failed",error:`client error report failed with ${i.status}`}}catch(i){return{status:"failed",error:Wr(i)}}}class _v extends b.Component{state={crashed:!1};static getDerivedStateFromError(){return{crashed:!0}}componentDidCatch(r,i){Jr({component:"ErrorBoundary",operation:"componentDidCatch",message:Wr(r)})}render(){return this.state.crashed?$.jsx("main",{className:"max-w-dashboard mx-auto px-4 sm:px-6 lg:px-8 py-12",children:$.jsxs("section",{className:"space-y-4",role:"alert",children:[$.jsx("h1",{className:"text-display font-semibold text-fg",children:"Dashboard view failed."}),$.jsx("p",{className:"text-body text-fg-muted",children:"The error was reported to the local dashboard log. Refresh to retry this view."})]})}):this.props.children}}function vI({label:t,summary:r}){const i=r.attention+r.watch;if(i===0||r.severity===null)return null;const s=i===1?"item":"items";return $.jsx("span",{"aria-label":`${t}: ${i} ${r.severity} ${s}`,className:`ml-1 align-super text-[0.65rem] leading-none tnum ${hI(r.severity)}`,children:i})}function hI(t){return t==="attention"?"text-accent":"text-warn"}function wv(t,r,i){try{const s=qu(t).getItem(r);return s===null?{status:"missing"}:{status:"found",value:s}}catch(s){return Vu(t,"getItem",r,i,s)}}function xv(t,r,i,s){try{return qu(t).setItem(r,i),{status:"stored"}}catch(u){return Vu(t,"setItem",r,s,u)}}function Ev(t,r,i){try{return qu(t).removeItem(r),{status:"stored"}}catch(s){return Vu(t,"removeItem",r,i,s)}}function qu(t){return t==="localStorage"?window.localStorage:window.sessionStorage}function Vu(t,r,i,s,u){const p=Wr(u);return Jr({component:s,operation:`${t}.${r}`,message:`${i}: ${p}`}),{status:"unavailable",error:p}}const nu="gascity:theme",ru="ThemeContext",Iv=b.createContext(null);function gI(){const t=wv("localStorage",nu,ru);return t.status==="found"&&(t.value==="light"||t.value==="dark")?t.value:"system"}function yI(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function _I(t){const r=document.documentElement;t==="system"?r.removeAttribute("data-theme"):r.setAttribute("data-theme",t)}function wI({children:t}){const[r,i]=b.useState(gI),[s,u]=b.useState(yI);b.useEffect(()=>{const y=window.matchMedia("(prefers-color-scheme: dark)"),E=()=>u(y.matches?"dark":"light");return y.addEventListener("change",E),()=>y.removeEventListener("change",E)},[]);const p=r==="system"?s:r,d=b.useCallback(y=>{i(y),y==="system"?Ev("localStorage",nu,ru):xv("localStorage",nu,y,ru),_I(y)},[]),m=b.useCallback(()=>{d(p==="dark"?"light":"dark")},[p,d]),g=b.useMemo(()=>({pref:r,resolved:p,set:d,toggle:m}),[r,p,d,m]);return $.jsx(Iv.Provider,{value:g,children:t})}function xI(){const t=b.useContext(Iv);if(t===null)throw new Error("useTheme must be used inside ");return t}const Sv={operatorAlias:"operator",operatorWireAlias:"human",decisionLabel:"needs/operator"},kv=b.createContext(Sv);function EI({operator:t,children:r}){return $.jsx(kv.Provider,{value:t,children:r})}function bv(){return b.useContext(kv)}function II(t){return t===void 0?Sv:{operatorAlias:t.operatorAlias,operatorWireAlias:t.operatorWireAlias,decisionLabel:t.decisionLabel}}const SI={ok:"text-ok",warn:"text-warn",stuck:"text-accent",neutral:"text-fg-muted"},kI={ok:"●",warn:"▲",stuck:"■",neutral:"·"};function bI({tone:t,label:r,glyph:i,trailing:s,className:u="",title:p}){return $.jsxs("span",{className:`inline-flex items-baseline gap-1.5 text-body ${SI[t]} ${u}`,title:p,children:[$.jsx("span",{"aria-hidden":!0,className:"text-[0.85em] leading-none translate-y-[1px]",children:i??kI[t]}),$.jsx("span",{children:r}),s&&$.jsx("span",{className:"text-fg-faint text-label uppercase tracking-wider",children:s})]})}function V6(t){switch(t){case"closed":return"neutral";case"in_progress":return"ok";case"blocked":return"stuck";default:return"warn"}}function W6(t){switch(t){case"active":case"running":return"ok";case"rate-limited":case"rate_limited":case"waiting":return"warn";case"failed":case"closed":case"errored":case"stuck":return"stuck";default:return"neutral"}}const zv=b.createContext(!1);function zI({readOnly:t,children:r}){return $.jsx(zv.Provider,{value:t,children:r})}function CI(){return b.useContext(zv)}function TI(t,r){return t?t.readOnly:r!==null}const Cv="Read-only mode: mutations are disabled";function H6(){return $.jsx(bI,{tone:"warn",label:"Read-only",title:Cv})}const BI="mayor";function RI(t){const{operator:r,sessionAliases:i,mailFromOrTo:s}=t,u=new Map;for(const A of i){const D=A.toLowerCase();u.has(D)||u.set(D,A)}for(const A of s){const D=A.toLowerCase();u.has(D)||u.set(D,A)}const p=r.toLowerCase(),d=new Set(s.map(A=>A.toLowerCase())),m=[r],g=[],y=[],E=[];for(const[A,D]of u)if(A!==p){if(A===BI){g.push(D);continue}d.has(A)?y.push(D):E.push(D)}const S=(A,D)=>A.toLowerCase().localeCompare(D.toLowerCase());y.sort(S),E.sort(S);const T=[{tier:"you",aliases:m}];return g.length>0&&T.push({tier:"mayor",aliases:g}),y.length>0&&T.push({tier:"active",aliases:y}),E.length>0&&T.push({tier:"other",aliases:E}),T}function PI(t,r){return t===r?"user":t}function G6(t){switch(t){case"you":return"You";case"mayor":return"Mayor";case"active":return"Active";case"other":return"Other"}}async function NI(){return ot().listSessions(xn("list supervisor sessions"))}async function J6(t){const r=await ot().sessionTranscript(xn("fetch supervisor session transcript"),t);return OI(r)}function K6(t){return(t.items??[]).map(AI)}function AI(t){const r={id:t.id,template:t.template,session_name:t.session_name,title:t.title,state:t.state,created_at:t.created_at,attached:t.attached,running:t.running,provider:t.provider};return t.alias!==void 0&&(r.alias=t.alias),t.reason!==void 0&&(r.reason=t.reason),t.display_name!==void 0&&(r.display_name=t.display_name),t.last_active!==void 0&&(r.last_active=t.last_active),t.rig!==void 0&&(r.rig=t.rig),t.pool!==void 0&&(r.pool=t.pool),t.agent_kind!==void 0&&(r.agent_kind=t.agent_kind),t.model!==void 0&&(r.model=t.model),t.context_pct!==void 0&&(r.context_pct=t.context_pct),t.context_window!==void 0&&(r.context_window=t.context_window),t.activity!==void 0&&(r.activity=t.activity),r}function OI(t,r=new Date().toISOString()){const i=t.turns??[];return{...t,turns:i,total_chars:i.reduce((s,u)=>s+u.text.length,0),captured_at:r,truncated:!1}}const ou="gascity.dashboard.viewingAs",Kr="ViewingAsContext",Mf=/^[a-z][a-z0-9_./-]{1,63}$/i,Ff=[3e4,9e4,27e4];function jI(t){if(!Number.isInteger(t)||t<0||t>=Ff.length)return null;const r=Ff[t];return r===void 0?null:r}const Tv=b.createContext(null);function Uf(t){const r=wv("sessionStorage",ou,Kr);if(r.status==="found"){const i=r.value;if(i.length>0&&i.length<=64)return i}return t}function Zl(t,r){t===r?Ev("sessionStorage",ou,Kr):xv("sessionStorage",ou,t,Kr)}function $I({children:t}){const r=bv(),{operatorAlias:i}=r,[s,u]=b.useState(()=>Uf(i)),p=b.useRef(i),[d,m]=b.useState([]),[g,y]=b.useState([]),[E,S]=b.useState(!1),[T,A]=b.useState(!1),D=b.useRef(!1),W=b.useRef(!0),O=b.useRef(null),H=b.useCallback(pe=>{u(pe),Zl(pe,i)},[i]),oe=b.useCallback(()=>{u(i),Zl(i,i)},[i]),Q=b.useCallback(async()=>{try{const pe=await NI();if(!W.current)return!0;const Re=new Set,ye=[];for(const Ze of pe.items??[]){if(typeof Ze.alias!="string"||!Mf.test(Ze.alias))continue;const Ke=Ze.alias.toLowerCase();Re.has(Ke)||(Re.add(Ke),ye.push(Ze.alias))}return m(ye),A(!1),!0}catch(pe){return Jr({component:Kr,operation:"loadAliases.sessions",message:Wr(pe)}),!1}},[]),G=b.useCallback(pe=>{if(!W.current)return;const Re=jI(pe);Re!==null&&(O.current=setTimeout(()=>{O.current=null,W.current&&Q().then(ye=>{W.current&&(ye||G(pe+1))}).catch(ye=>{Jr({component:Kr,operation:"loadAliases.sessionsRetry",message:Wr(ye)})})},Re))},[Q]),ee=b.useCallback(()=>{if(D.current)return;D.current=!0,S(!0);let pe=2;const Re=()=>{pe-=1,pe===0&&W.current&&S(!1)};Q().then(ye=>{W.current&&(ye||(A(!0),G(0)))}).finally(Re),Zu("all",i,r).then(ye=>{if(!W.current)return;const Ze=new Set,Ke=[];for(const et of ye.items)for(const Qe of[et.from,et.to]){if(typeof Qe!="string"||Qe.length===0||!Mf.test(Qe))continue;const kt=Qe.toLowerCase();Ze.has(kt)||(Ze.add(kt),Ke.push(Qe))}y(Ke)}).catch(ye=>{Jr({component:Kr,operation:"loadAliases.mail",message:Wr(ye)})}).finally(Re)},[Q,G,i,r]);b.useEffect(()=>(W.current=!0,()=>{W.current=!1,O.current!==null&&(clearTimeout(O.current),O.current=null)}),[]),b.useEffect(()=>{const pe=p.current;p.current=i,pe!==i&&s===pe&&u(Uf(i))},[i,s]);const ue=b.useMemo(()=>RI({operator:i,sessionAliases:d.includes(s)?d:[...d,s],mailFromOrTo:g}),[d,g,s,i]),de=b.useMemo(()=>({viewingAs:{alias:s,isOperator:s===i},setAlias:H,resetToOperator:oe,aliasBuckets:ue,aliasesLoading:E,sessionsUnavailable:T,loadAliases:ee}),[s,i,H,oe,ue,E,T,ee]);return b.useEffect(()=>{const pe=()=>{document.hidden&&s!==i&&(u(i),Zl(i,i))};return document.addEventListener("visibilitychange",pe),()=>document.removeEventListener("visibilitychange",pe)},[s,i]),$.jsx(Tv.Provider,{value:de,children:t})}function LI(){const t=b.useContext(Tv);if(t===null)throw new Error("useViewingAs must be inside ");return t}const DI={id:"activity",kind:"core",path:"/activity",nav:{label:"Activity",order:55},element:b.lazy(()=>wn(()=>import("./Activity-DTboxwTI.js"),__vite__mapDeps([0,1,2,3,4])).then(t=>({default:t.ActivityPage})))},MI={id:"health",kind:"core",path:"/health",nav:{label:"Health",order:60},element:b.lazy(()=>wn(()=>import("./Health-C5mLLJQ2.js"),__vite__mapDeps([5,1,2,4,6,3])).then(t=>({default:t.HealthPage})))},Bv=[DI,MI],FI={views:"views"};function UI(t,r){console.warn(`[${t}] ${r}`)}function Rv(t,r){const i=new Set(r??[]);return t.filter(s=>s.kind==="core"||i.has(s.id))}const ZI={};function qI(t,r){const i=[];if(r!==null){const d=ZI[r];if(d!==void 0){if(t.some(g=>g.id===d.target))return{view:null,redirectTo:d.redirectTo,source:"env",warnings:i};i.push(`DEFAULT_VIEW="${r}" alias targets the "${d.target}" view, which is not enabled in this deployment (known enabled ids: ${t.map(g=>g.id).join(", ")||"(none)"}); falling through to descriptor / ambient-home`)}else{const m=t.find(g=>g.id===r);if(m!==void 0)return{view:m,source:"env",warnings:i};i.push(`DEFAULT_VIEW="${r}" does not match any enabled view (known enabled ids: ${t.map(g=>g.id).join(", ")||"(none)"}); falling through to descriptor / ambient-home`)}}const s=t.filter(d=>d.defaultRoute===!0),[u,...p]=s;if(u!==void 0&&p.length===0)return{view:u,source:"descriptor",warnings:i};if(u!==void 0){const m=[...s].sort(WI)[0]??u;return i.push(`multiple views declare defaultRoute: true (${s.map(g=>g.id).join(", ")}); picking "${m.id}" by lowest nav.order`),{view:m,source:"descriptor",warnings:i}}return{view:null,source:"fallback",warnings:i}}function VI(t,r){const i=qI(t,r);for(const s of i.warnings)UI(FI.views,s);return i}function WI(t,r){const i=t.nav?.order??Number.POSITIVE_INFINITY,s=r.nav?.order??Number.POSITIVE_INFINITY;return i!==s?i-s:t.id.localeCompare(r.id)}const HI=[{to:"/",label:"Home",end:!0,order:10},{to:"/agents",label:"Agents",order:20},{to:"/beads",label:"Beads",order:30},{to:"/runs",label:"Runs",order:40},{to:"/mail",label:"Mail",order:50}],GI={"/agents":"agents","/beads":"beads","/runs":"runs","/mail":"mail","/activity":"activity","/health":"health"};function JI(){const{resolved:t,toggle:r}=xI(),{viewingAs:i}=LI(),{operatorAlias:s}=bv(),u=CI(),p=qy(),{data:d}=mn("config",()=>Yr.config()),{data:m}=mn("cities",()=>ot().listCities()),g=Ba(),y=m?.items??[],E=g??d?.cityName??"",S=E===""||y.some(H=>H.name===E),T=y.length>1||!S,A=H=>{H!==g&&window.location.assign(`/city/${encodeURIComponent(H)}/`)},D=b.useMemo(()=>{const oe=Rv(Bv,d?.enabledModules??null).flatMap(Q=>Q.nav===null?[]:[{to:Q.path,label:Q.nav.label,end:Q.path==="/",order:Q.nav.order}]);return[...HI,...oe].sort((Q,G)=>Q.order-G.order)},[d?.enabledModules]),{pathname:W}=_n(),O=!i.isOperator&&W.startsWith("/mail");return $.jsx("header",{className:"border-b border-rule",children:$.jsxs("div",{className:"max-w-dashboard mx-auto px-4 sm:px-6 lg:px-8 py-5 flex items-baseline gap-x-6 lg:gap-x-8 gap-y-2 flex-wrap",children:[$.jsxs("div",{className:"flex items-baseline gap-3 min-w-0",children:[$.jsx("span",{className:"text-title font-semibold tracking-tight text-fg",children:"gas city"}),$.jsx("span",{className:"text-fg-muted","aria-hidden":"true",children:"·"}),T?$.jsx("label",{className:"sr-only",htmlFor:"city-switcher",children:"Switch city"}):null,T?$.jsxs("select",{id:"city-switcher",value:E,onChange:H=>A(H.target.value),className:"text-label uppercase tracking-wider text-fg-muted bg-transparent border-0 focus-mark cursor-pointer hover:text-fg transition-colors duration-150 ease-out-quart",children:[!S&&E!==""?$.jsxs("option",{value:E,disabled:!0,children:[E," (unknown)"]}):null,y.map(H=>$.jsxs("option",{value:H.name,children:[H.name,H.running?"":" (stopped)"]},H.name))]}):$.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:E||"city"}),O&&$.jsxs("span",{className:"text-label uppercase tracking-wider text-accent ml-3",children:["· reading as ",PI(i.alias,s)]}),u&&$.jsx("span",{title:Cv,className:"text-label uppercase tracking-wider text-warn ml-3",children:"· read-only"})]}),$.jsx("nav",{className:"flex-1",children:$.jsx("ul",{className:"flex items-baseline gap-x-5 lg:gap-x-7 gap-y-1 flex-wrap",children:D.map(H=>{const oe=GI[H.to];return $.jsx("li",{children:$.jsxs(W0,{to:H.to,end:H.end??!1,className:({isActive:Q})=>["text-title transition-colors duration-150 ease-out-quart focus-mark",Q?"text-fg font-semibold":"text-fg-muted font-medium hover:text-fg"].join(" "),children:[H.label,oe!==void 0&&$.jsx(vI,{label:H.label,summary:p.byDomain[oe]})]})},H.to)})})}),$.jsx("button",{type:"button",onClick:r,"aria-label":`Switch to ${t==="dark"?"light":"dark"} theme`,className:"text-label uppercase tracking-wider text-fg-muted hover:text-fg transition-colors duration-150 ease-out-quart focus-mark",children:t==="dark"?"Light":"Dark"})]})})}function KI({children:t}){return $.jsxs("div",{className:"min-h-screen bg-surface text-fg antialiased",children:[$.jsx(JI,{}),$.jsx("main",{className:"max-w-dashboard mx-auto px-4 sm:px-6 lg:px-8 py-12",children:t})]})}const Pv=b.createContext(null);function QI({children:t,intervalMs:r=1e3}){const[i,s]=b.useState(()=>Date.now());return b.useEffect(()=>{const u=window.setInterval(()=>{s(Date.now())},r);return()=>{window.clearInterval(u)}},[r]),$.jsx(Pv.Provider,{value:i,children:t})}function Q6(){const t=b.useContext(Pv);if(t===null)throw new Error("useNow must be called inside a NowProvider.");return t}const YI=2e3,XI=2500;function e6(t,r,i={}){const[s,u]=b.useState("connecting"),p=b.useRef(r);p.current=r;const d=b.useRef(i.matches);d.current=i.matches;const m=b.useRef(i.coalesceMs);m.current=i.coalesceMs;const g=t.join(","),y=b.useRef(0),E=b.useRef(null);return b.useEffect(()=>{if(t.length===0){u("closed");return}let S=null,T=!1,A=null,D=null,W=1e3,O=!1;const H=()=>{D!==null&&(clearTimeout(D),D=null)},oe=ue=>{O||(O=!0,t6(ue))},Q=()=>{y.current=Date.now(),p.current()},G=()=>{const ue=m.current??XI,de=Date.now()-y.current;de>=ue?(E.current&&(clearTimeout(E.current),E.current=null),Q()):E.current===null&&(E.current=setTimeout(()=>{E.current=null,T||Q()},ue-de))},ee=()=>{const ue=globalThis.EventSource;if(typeof ue!="function"){u("closed");return}const de=Ba();if(de===null){u("closed");return}const pe=new ue(ot().cityEventStreamUrl(de));S=pe,u("connecting"),D=setTimeout(()=>{T||S!==pe||pe.readyState===ue.CLOSED||u("open")},YI),S.onopen=()=>{T||(H(),u("open"),W=1e3)};const Re=ye=>{if(T)return;let Ze=null;try{Ze=JSON.parse(ye.data)}catch{u("degraded"),oe("invalid JSON");return}if(!n6(Ze)){u("degraded"),oe("missing string event type");return}const Ke=Ze.type;if(typeof Ke!="string"){u("degraded"),oe("missing string event type");return}u("open");for(const et of t)if(Ke.startsWith(et)){const Qe=Ze;(d.current?.(Qe)??!0)&&G();break}};S.onmessage=Re,S.addEventListener("event",Re),S.onerror=()=>{T||(H(),u("closed"),S?.close(),S=null,A=setTimeout(()=>{W=Math.min(W*2,3e4),ee()},W))}};return ee(),()=>{T=!0,A&&clearTimeout(A),H(),E.current&&(clearTimeout(E.current),E.current=null),S?.close()}},[g]),s}function t6(t){Jr({component:"gc-events",operation:"parse event",message:`Malformed gc event payload: ${t}.`})}function n6(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)}const r6=60*1e3;async function Da(){const t=new Date().toISOString();try{const r=await Yr.runSummary();return{source:"runs",status:"fresh",fetchedAt:t,staleAt:new Date(Date.parse(t)+r6).toISOString(),error:{kind:"none"},data:r}}catch(r){return{source:"runs",status:"error",error:s6(r,"formula runs unavailable")}}}function o6(){return Da()}function Y6(){return Da()}function i6(){return Da()}function a6(){return Da()}function s6(t,r){return t instanceof Error&&t.message.trim().length>0?t.message:r}const Zf=1e4,l6=[2e3,5e3,1e4];function u6(){const t=Ba(),r=b.useRef(null),i=b.useRef(!1),s=b.useCallback(async()=>{const ee=await o6().catch(de=>({source:"runs",status:"error",error:de instanceof Error?de.message:"formula runs unavailable"}));if(ee.status!=="error")return i.current=!1,ee;const ue=r.current;return ue===null?ee:(i.current=!0,{...ue,status:"stale"})},[]),u=b.useCallback(async()=>{const ee=await i6().catch(de=>({source:"runs",status:"error",error:de instanceof Error?de.message:"formula runs unavailable"}));if(ee.status!=="error")return ee;const ue=r.current;return ue===null?ee:(i.current=!0,{...ue,status:"stale"})},[]),{data:p,loading:d,error:m,refresh:g,cheapRefresh:y}=mn(`runs:summary:${t??"no-city"}`,a6,{refreshFetcher:s,sseRefreshFetcher:u});p!==void 0&&p.status!=="error"&&(r.current=p);const E=p??null,S=b.useRef(null);S.current=E?.status??null;const T=b.useRef(d);T.current=d;const A=b.useRef(0),D=b.useRef(null);b.useEffect(()=>{if(E===null||E.status==="error")return;const ee=t??"no-city";D.current!==ee&&(D.current=ee,g().catch(()=>{D.current=null}))},[t,g,E]);const W=b.useRef(0);b.useEffect(()=>{if(E===null)return;if(!(E.status==="error"?!0:i.current||E.data.lanesPartial===!0&&E.data.lanes.length===0&&E.data.blockedLanes.length===0)){W.current=0;return}const ue=l6[W.current];if(ue===void 0)return;W.current+=1;const de=setTimeout(()=>{g()},ue);return()=>clearTimeout(de)},[E,g]);const O=b.useRef(!1),H=b.useRef(null),oe=b.useCallback(()=>{H.current!==null&&(clearTimeout(H.current),H.current=null),A.current=Date.now(),y().catch(()=>{A.current=0})},[y]),Q=b.useCallback(()=>{if(S.current===null||S.current==="fixture")return;if(T.current){O.current=!0;return}Date.now()-A.current{if(d||!O.current)return;O.current=!1;const ee=Math.max(0,Zf-(Date.now()-A.current));return H.current=setTimeout(oe,ee),()=>{H.current!==null&&(clearTimeout(H.current),H.current=null)}},[d,oe]);const G=e6([ly.bead],Q);return{source:p,loading:d,error:m,refresh:g,sseState:G}}const Nv=b.createContext(null);function c6({children:t}){const r=u6();return $.jsx(Nv.Provider,{value:r,children:t})}function d6(){const t=b.useContext(Nv);if(t===null)throw new Error("useRunSummary must be used within a RunSummaryProvider");return t}const p6=b.lazy(()=>wn(()=>import("./Agents-CF9gHKR0.js"),__vite__mapDeps([7,8,1,9,10,2,11,3,12,6,13,14])).then(t=>({default:t.AgentsPage}))),f6=b.lazy(()=>wn(()=>import("./AgentDetail-DVT9Be-a.js"),__vite__mapDeps([15,16,6,17,12,3,10,2,8,14])).then(t=>({default:t.AgentDetailPage}))),m6=b.lazy(()=>wn(()=>import("./AmbientHome-usE4zKNv.js"),__vite__mapDeps([18,2])).then(t=>({default:t.AmbientHomePage}))),v6=b.lazy(()=>wn(()=>import("./Beads-DJjixOgD.js"),__vite__mapDeps([19,1,16,6,17,12,3,10,20,9,2,14])).then(t=>({default:t.BeadsPage}))),h6=b.lazy(()=>wn(()=>import("./Mail-767k9Nkh.js"),__vite__mapDeps([21,9,1,20,13,10,2,17,3])).then(t=>({default:t.MailPage}))),g6=b.lazy(()=>wn(()=>import("./FormulaRunDetail-CFys0Xia.js"),__vite__mapDeps([22,2,16,6,17,12,3,10,23])).then(t=>({default:t.FormulaRunDetailPage}))),y6=b.lazy(()=>wn(()=>import("./Runs-BCTFHOlQ.js"),__vite__mapDeps([24,1,2,11,3,23])).then(t=>({default:t.RunsPage})));function _6(){const{data:t,error:r}=mn("config",()=>Yr.config()),i=t?.enabledModules??null,s=t?.defaultView??null,u=TI(t,r),p=II(t),d=b.useMemo(()=>Rv(Bv,i),[i]),m=b.useMemo(()=>VI(d,s),[d,s]),g=m.view?.element??null,y=m.redirectTo??null;return $.jsx(EI,{operator:p,children:$.jsx($I,{children:$.jsx(QI,{children:$.jsx(zI,{readOnly:u,children:$.jsx(c6,{children:$.jsx(w6,{operator:p,children:$.jsxs(KI,{children:[r!==null&&$.jsx(E6,{message:r}),$.jsx(x6,{defaultRedirectTo:y,DefaultViewElement:g,enabledViews:d})]})})})})})})})}function w6({operator:t,children:r}){const{source:i}=d6(),s=iI(t,i);return $.jsx(Zy,{contributors:s,children:r})}function x6({defaultRedirectTo:t,DefaultViewElement:r,enabledViews:i}){const{pathname:s}=_n();return $.jsx(_v,{children:$.jsx(b.Suspense,{fallback:null,children:$.jsxs(N0,{children:[$.jsx(on,{path:"/",element:t!==null?$.jsx(R0,{to:t,replace:!0}):r!==null?$.jsx(r,{}):$.jsx(m6,{})}),$.jsx(on,{path:"/agents",element:$.jsx(p6,{})}),$.jsx(on,{path:"/agents/:slug",element:$.jsx(f6,{})}),$.jsx(on,{path:"/beads",element:$.jsx(v6,{})}),$.jsx(on,{path:"/runs",element:$.jsx(y6,{})}),$.jsx(on,{path:"/runs/:runId",element:$.jsx(g6,{})}),$.jsx(on,{path:"/mail",element:$.jsx(h6,{})}),i.map(u=>{const p=u.element;return $.jsx(on,{path:u.path,element:$.jsx(p,{})},u.id)}),$.jsx(on,{path:"*",element:$.jsx(I6,{})})]})})},s)}function E6({message:t}){return $.jsxs("section",{role:"alert",className:"mb-8 border border-warn/40 rounded-sm px-4 py-3 text-body text-fg-muted",children:[$.jsx("span",{className:"text-warn",children:"config unavailable:"})," ",t," · some controls may be disabled until it loads."]})}function I6(){return $.jsxs("section",{"aria-labelledby":"not-found-title",className:"space-y-3",children:[$.jsx("h1",{id:"not-found-title",className:"text-5xl font-semibold tracking-tight text-fg",children:"Page not found"}),$.jsx("p",{className:"text-title text-fg-muted",children:"No dashboard route matches this path."})]})}const S6={default:"border border-rule text-fg-muted hover:text-fg hover:bg-surface-tint",accent:"border border-accent text-accent hover:bg-accent hover:text-surface",quiet:"border border-transparent text-fg-muted hover:text-fg"},k6={sm:"px-2.5 py-1 text-label uppercase tracking-wider",md:"px-3.5 py-1.5 text-body"};function b6({tone:t="default",size:r="sm",className:i="",children:s,...u}){return $.jsx("button",{...u,className:`inline-flex items-center gap-1.5 rounded-sm transition-colors duration-150 ease-out-quart focus-mark disabled:opacity-40 disabled:cursor-not-allowed ${S6[t]} ${k6[r]} ${i}`,children:s})}const z6="https://docs.gascity.com/getting-started/quickstart",C6=/^\/city\/([^/]+)(?:\/|$)/;function T6(t){const r=C6.exec(t);if(r===null)return null;const i=r[1];if(i===void 0)return null;let s;try{s=decodeURIComponent(i)}catch{return null}return om.test(s)?{cityName:s,basename:`/city/${i}`}:null}function B6(){const t=b.useMemo(()=>T6(window.location.pathname),[]),[r,i]=b.useState({phase:"loading"}),[s,u]=b.useState(0),p=b.useCallback(()=>{i({phase:"loading"}),u(d=>d+1)},[]);return b.useEffect(()=>{let d=!1;return i({phase:"loading"}),ot().listCities().then(m=>{if(d)return;const g=m.items??[];if(t!==null){const E=g.some(S=>S.name===t.cityName);i(E?{phase:"mount"}:{phase:"unknown-city",cities:g});return}const y=g[0];if(y===void 0){i({phase:"empty"});return}window.location.replace(`/city/${encodeURIComponent(y.name)}/`)}).catch(m=>{if(!d){if(t!==null){i({phase:"mount"});return}i({phase:"error",message:m instanceof Error?m.message:"failed to load cities"})}}),()=>{d=!0}},[t,s]),t!==null&&r.phase==="mount"?(vy(t.cityName),$.jsx(U0,{basename:t.basename,future:{v7_relativeSplatPath:!0,v7_startTransition:!0},children:$.jsx(_6,{})})):r.phase==="unknown-city"&&t!==null?$.jsx(R6,{cityName:t.cityName,cities:r.cities}):r.phase==="empty"?$.jsx(P6,{}):r.phase==="error"?$.jsx(N6,{message:r.message,onRetry:p}):$.jsx(Ma,{children:$.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Resolving city…"})})}function Ma({children:t}){return $.jsx("div",{className:"min-h-screen bg-surface text-fg antialiased flex items-center justify-center px-6",children:$.jsx("div",{className:"max-w-prose w-full space-y-4",children:t})})}function R6({cityName:t,cities:r}){return $.jsx(Ma,{children:$.jsxs("section",{role:"alert",className:"space-y-4",children:[$.jsxs("h1",{className:"text-display font-semibold text-fg",children:["City “",t,"” is not registered on this supervisor."]}),r.length>0?$.jsxs("div",{className:"space-y-2",children:[$.jsx("p",{className:"text-body text-fg-muted",children:"Available cities:"}),$.jsx("ul",{className:"space-y-1",children:r.map(i=>$.jsxs("li",{children:[$.jsx("a",{href:`/city/${encodeURIComponent(i.name)}/`,className:"text-body text-accent hover:underline focus-mark",children:i.name}),i.running?null:$.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted ml-2",children:"· stopped"})]},i.name))})]}):$.jsx(Av,{})]})})}function P6(){return $.jsx(Ma,{children:$.jsxs("section",{className:"space-y-4",children:[$.jsx("h1",{className:"text-display font-semibold text-fg",children:"No cities are registered on this supervisor."}),$.jsx(Av,{})]})})}function Av(){return $.jsxs("div",{className:"space-y-3",children:[$.jsx("p",{className:"text-body text-fg-muted",children:"Create one from a terminal:"}),$.jsx("pre",{className:"text-body bg-surface-tint rounded-sm px-3 py-2 overflow-x-auto",children:$.jsx("code",{children:"gc init ~/my-city"})}),$.jsxs("p",{className:"text-body text-fg-muted",children:[$.jsx("code",{children:"gc init"})," bootstraps the city directory, registers it with the supervisor, and starts the orchestrator. Then refresh this page. See the"," ",$.jsx("a",{href:z6,target:"_blank",rel:"noreferrer",className:"text-accent hover:underline focus-mark",children:"getting-started guide"})," ","for the full walkthrough."]})]})}function N6({message:t,onRetry:r}){return $.jsx(Ma,{children:$.jsxs("section",{role:"alert",className:"space-y-4",children:[$.jsx("h1",{className:"text-display font-semibold text-fg",children:"Could not load cities."}),$.jsx("p",{className:"text-body text-fg-muted",children:t}),$.jsx(b6,{onClick:r,children:"Retry"})]})})}const Ov=document.getElementById("root");if(!Ov)throw new Error("missing #root");Ug.createRoot(Ov).render($.jsx(Vf.StrictMode,{children:$.jsx(wI,{children:$.jsx(_v,{children:$.jsx(B6,{})})})}));export{Fl as $,Zu as A,b6 as B,Ay as C,wv as D,xv as E,Y6 as F,ly as G,Ba as H,ot as I,xn as J,O6 as K,V0 as L,PI as M,G6 as N,Uu as O,n4 as P,U6 as Q,H6 as R,bI as S,py as T,dy as U,F6 as V,M6 as W,Yr as X,im as Y,Vy as Z,Ny as _,qy as a,D6 as a0,Wn as a1,K6 as a2,V6 as a3,J6 as a4,OI as a5,Lf as a6,oy as a7,d6 as a8,E4 as a9,I4 as aa,UE as ab,mn as b,YE as c,VE as d,X0 as e,e6 as f,CI as g,j6 as h,Cv as i,$ as j,$6 as k,NI as l,_4 as m,q6 as n,Z6 as o,Wr as p,A6 as q,b as r,W6 as s,uu as t,Q6 as u,LI as v,bv as w,Jr as x,L6 as y,Jt as z}; diff --git a/internal/api/dashboardspa/dist/assets/index-CJ6RRl2D.js b/internal/api/dashboardspa/dist/assets/index-CJ6RRl2D.js new file mode 100644 index 0000000000..f05cfbd543 --- /dev/null +++ b/internal/api/dashboardspa/dist/assets/index-CJ6RRl2D.js @@ -0,0 +1,10 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/Activity-odvexmkt.js","assets/routeHighlight-B30gQO2o.js","assets/PageHeader-4s_Bnmfl.js","assets/time-D9v0saHV.js","assets/useVisibleRefresh-DJ0jEjH6.js","assets/Health-Dtx4mLLZ.js","assets/format-fte2CeYD.js","assets/Agents-B1unG_7M.js","assets/context-window-Cu9zl36t.js","assets/projectOf-B27nlS9X.js","assets/constants-BJUwiA6r.js","assets/SseIndicator-Cbaw_u69.js","assets/LiveSessionPeek-D86UU9cf.js","assets/Table-CU8DfQGc.js","assets/agentReads-D6V_h6J8.js","assets/AgentDetail-CX9z-pqC.js","assets/BeadDetailModal-3OfdZfdZ.js","assets/Field-9mEFYSnz.js","assets/AmbientHome-Tal9JCSE.js","assets/Beads-BlpKAjIK.js","assets/useListFilters-DQXiYJvO.js","assets/Mail-CA7kddiW.js","assets/FormulaRunDetail-C-9EnNQt.js","assets/StageLadder-DMyiD1Pv.js","assets/Runs-C0j-IP1W.js"])))=>i.map(i=>d[i]); +function Em(r,l){for(var s=0;su[c]})}}}return Object.freeze(Object.defineProperty(r,Symbol.toStringTag,{value:"Module"}))}(function(){const l=document.createElement("link").relList;if(l&&l.supports&&l.supports("modulepreload"))return;for(const c of document.querySelectorAll('link[rel="modulepreload"]'))u(c);new MutationObserver(c=>{for(const d of c)if(d.type==="childList")for(const p of d.addedNodes)p.tagName==="LINK"&&p.rel==="modulepreload"&&u(p)}).observe(document,{childList:!0,subtree:!0});function s(c){const d={};return c.integrity&&(d.integrity=c.integrity),c.referrerPolicy&&(d.referrerPolicy=c.referrerPolicy),c.crossOrigin==="use-credentials"?d.credentials="include":c.crossOrigin==="anonymous"?d.credentials="omit":d.credentials="same-origin",d}function u(c){if(c.ep)return;c.ep=!0;const d=s(c);fetch(c.href,d)}})();function Pf(r){return r&&r.__esModule&&Object.prototype.hasOwnProperty.call(r,"default")?r.default:r}var ks={exports:{}},br={},Cs={exports:{}},ie={};var Gc;function xm(){if(Gc)return ie;Gc=1;var r=Symbol.for("react.element"),l=Symbol.for("react.portal"),s=Symbol.for("react.fragment"),u=Symbol.for("react.strict_mode"),c=Symbol.for("react.profiler"),d=Symbol.for("react.provider"),p=Symbol.for("react.context"),m=Symbol.for("react.forward_ref"),y=Symbol.for("react.suspense"),x=Symbol.for("react.memo"),k=Symbol.for("react.lazy"),R=Symbol.iterator;function T(S){return S===null||typeof S!="object"?null:(S=R&&S[R]||S["@@iterator"],typeof S=="function"?S:null)}var $={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},z=Object.assign,M={};function P(S,L,re){this.props=S,this.context=L,this.refs=M,this.updater=re||$}P.prototype.isReactComponent={},P.prototype.setState=function(S,L){if(typeof S!="object"&&typeof S!="function"&&S!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,S,L,"setState")},P.prototype.forceUpdate=function(S){this.updater.enqueueForceUpdate(this,S,"forceUpdate")};function D(){}D.prototype=P.prototype;function G(S,L,re){this.props=S,this.context=L,this.refs=M,this.updater=re||$}var Y=G.prototype=new D;Y.constructor=G,z(Y,P.prototype),Y.isPureReactComponent=!0;var q=Array.isArray,b=Object.prototype.hasOwnProperty,ee={current:null},te={key:!0,ref:!0,__self:!0,__source:!0};function ne(S,L,re){var le,ae={},ue=null,he=null;if(L!=null)for(le in L.ref!==void 0&&(he=L.ref),L.key!==void 0&&(ue=""+L.key),L)b.call(L,le)&&!te.hasOwnProperty(le)&&(ae[le]=L[le]);var fe=arguments.length-2;if(fe===1)ae.children=re;else if(1>>1,L=B[S];if(0>>1;Sc(ae,V))uec(he,ae)?(B[S]=he,B[ue]=V,S=ue):(B[S]=ae,B[le]=V,S=le);else if(uec(he,V))B[S]=he,B[ue]=V,S=ue;else break e}}return J}function c(B,J){var V=B.sortIndex-J.sortIndex;return V!==0?V:B.id-J.id}if(typeof performance=="object"&&typeof performance.now=="function"){var d=performance;r.unstable_now=function(){return d.now()}}else{var p=Date,m=p.now();r.unstable_now=function(){return p.now()-m}}var y=[],x=[],k=1,R=null,T=3,$=!1,z=!1,M=!1,P=typeof setTimeout=="function"?setTimeout:null,D=typeof clearTimeout=="function"?clearTimeout:null,G=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function Y(B){for(var J=s(x);J!==null;){if(J.callback===null)u(x);else if(J.startTime<=B)u(x),J.sortIndex=J.expirationTime,l(y,J);else break;J=s(x)}}function q(B){if(M=!1,Y(B),!z)if(s(y)!==null)z=!0,qe(b);else{var J=s(x);J!==null&&Re(q,J.startTime-B)}}function b(B,J){z=!1,M&&(M=!1,D(ne),ne=-1),$=!0;var V=T;try{for(Y(J),R=s(y);R!==null&&(!(R.expirationTime>J)||B&&!_e());){var S=R.callback;if(typeof S=="function"){R.callback=null,T=R.priorityLevel;var L=S(R.expirationTime<=J);J=r.unstable_now(),typeof L=="function"?R.callback=L:R===s(y)&&u(y),Y(J)}else u(y);R=s(y)}if(R!==null)var re=!0;else{var le=s(x);le!==null&&Re(q,le.startTime-J),re=!1}return re}finally{R=null,T=V,$=!1}}var ee=!1,te=null,ne=-1,ye=5,oe=-1;function _e(){return!(r.unstable_now()-oeB||125S?(B.sortIndex=V,l(x,B),s(y)===null&&B===s(x)&&(M?(D(ne),ne=-1):M=!0,Re(q,V-S))):(B.sortIndex=L,l(y,B),z||$||(z=!0,qe(b))),B},r.unstable_shouldYield=_e,r.unstable_wrapCallback=function(B){var J=T;return function(){var V=T;T=J;try{return B.apply(this,arguments)}finally{T=V}}}})(Ns)),Ns}var Zc;function Nm(){return Zc||(Zc=1,Rs.exports=Rm()),Rs.exports}var bc;function Tm(){if(bc)return et;bc=1;var r=Ws(),l=Nm();function s(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),y=Object.prototype.hasOwnProperty,x=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,k={},R={};function T(e){return y.call(R,e)?!0:y.call(k,e)?!1:x.test(e)?R[e]=!0:(k[e]=!0,!1)}function $(e,t,n,i){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return i?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function z(e,t,n,i){if(t===null||typeof t>"u"||$(e,t,n,i))return!0;if(i)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function M(e,t,n,i,o,a,f){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=i,this.attributeNamespace=o,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=a,this.removeEmptyString=f}var P={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){P[e]=new M(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];P[t]=new M(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){P[e]=new M(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){P[e]=new M(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){P[e]=new M(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){P[e]=new M(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){P[e]=new M(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){P[e]=new M(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){P[e]=new M(e,5,!1,e.toLowerCase(),null,!1,!1)});var D=/[\-:]([a-z])/g;function G(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(D,G);P[t]=new M(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(D,G);P[t]=new M(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(D,G);P[t]=new M(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){P[e]=new M(e,1,!1,e.toLowerCase(),null,!1,!1)}),P.xlinkHref=new M("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){P[e]=new M(e,1,!1,e.toLowerCase(),null,!0,!0)});function Y(e,t,n,i){var o=P.hasOwnProperty(t)?P[t]:null;(o!==null?o.type!==0:i||!(2h||o[f]!==a[h]){var v=` +`+o[f].replace(" at new "," at ");return e.displayName&&v.includes("")&&(v=v.replace("",e.displayName)),v}while(1<=f&&0<=h);break}}}finally{re=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?L(e):""}function ae(e){switch(e.tag){case 5:return L(e.type);case 16:return L("Lazy");case 13:return L("Suspense");case 19:return L("SuspenseList");case 0:case 2:case 15:return e=le(e.type,!1),e;case 11:return e=le(e.type.render,!1),e;case 1:return e=le(e.type,!0),e;default:return""}}function ue(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case te:return"Fragment";case ee:return"Portal";case ye:return"Profiler";case ne:return"StrictMode";case Me:return"Suspense";case Ie:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case _e:return(e.displayName||"Context")+".Consumer";case oe:return(e._context.displayName||"Context")+".Provider";case Le:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case rt:return t=e.displayName||null,t!==null?t:ue(e.type)||"Memo";case qe:t=e._payload,e=e._init;try{return ue(e(t))}catch{}}return null}function he(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return ue(t);case 8:return t===ne?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function fe(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function Se(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function it(e){var t=Se(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),i=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var o=n.get,a=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return o.call(this)},set:function(f){i=""+f,a.call(this,f)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return i},setValue:function(f){i=""+f},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function li(e){e._valueTracker||(e._valueTracker=it(e))}function bs(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),i="";return e&&(i=Se(e)?e.checked?"true":"false":e.value),e=i,e!==n?(t.setValue(e),!0):!1}function oi(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Pl(e,t){var n=t.checked;return V({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function ea(e,t){var n=t.defaultValue==null?"":t.defaultValue,i=t.checked!=null?t.checked:t.defaultChecked;n=fe(t.value!=null?t.value:n),e._wrapperState={initialChecked:i,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function ta(e,t){t=t.checked,t!=null&&Y(e,"checked",t,!1)}function Al(e,t){ta(e,t);var n=fe(t.value),i=t.type;if(n!=null)i==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(i==="submit"||i==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Ll(e,t.type,n):t.hasOwnProperty("defaultValue")&&Ll(e,t.type,fe(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function na(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var i=t.type;if(!(i!=="submit"&&i!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function Ll(e,t,n){(t!=="number"||oi(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var mr=Array.isArray;function Dn(e,t,n,i){if(e=e.options,t){t={};for(var o=0;o"+t.valueOf().toString()+"",t=si.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function hr(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var vr={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},_d=["Webkit","ms","Moz","O"];Object.keys(vr).forEach(function(e){_d.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),vr[t]=vr[e]})});function aa(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||vr.hasOwnProperty(e)&&vr[e]?(""+t).trim():t+"px"}function ua(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var i=n.indexOf("--")===0,o=aa(n,t[n],i);n==="float"&&(n="cssFloat"),i?e.setProperty(n,o):e[n]=o}}var Rd=V({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function jl(e,t){if(t){if(Rd[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(s(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(s(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(s(61))}if(t.style!=null&&typeof t.style!="object")throw Error(s(62))}}function Ml(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var Dl=null;function zl(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var $l=null,zn=null,$n=null;function ca(e){if(e=$r(e)){if(typeof $l!="function")throw Error(s(280));var t=e.stateNode;t&&(t=Ai(t),$l(e.stateNode,e.type,t))}}function fa(e){zn?$n?$n.push(e):$n=[e]:zn=e}function da(){if(zn){var e=zn,t=$n;if($n=zn=null,ca(e),t)for(e=0;e>>=0,e===0?32:31-(zd(e)/$d|0)|0}var di=64,pi=4194304;function Sr(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function mi(e,t){var n=e.pendingLanes;if(n===0)return 0;var i=0,o=e.suspendedLanes,a=e.pingedLanes,f=n&268435455;if(f!==0){var h=f&~o;h!==0?i=Sr(h):(a&=f,a!==0&&(i=Sr(a)))}else f=n&~o,f!==0?i=Sr(f):a!==0&&(i=Sr(a));if(i===0)return 0;if(t!==0&&t!==i&&(t&o)===0&&(o=i&-i,a=t&-t,o>=a||o===16&&(a&4194240)!==0))return t;if((i&4)!==0&&(i|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=i;0n;n++)t.push(e);return t}function Er(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-gt(t),e[t]=n}function Vd(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var i=e.eventTimes;for(e=e.expirationTimes;0=Pr),Ua=" ",Fa=!1;function Va(e,t){switch(e){case"keyup":return vp.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Wa(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Fn=!1;function gp(e,t){switch(e){case"compositionend":return Wa(t);case"keypress":return t.which!==32?null:(Fa=!0,Ua);case"textInput":return e=t.data,e===Ua&&Fa?null:e;default:return null}}function wp(e,t){if(Fn)return e==="compositionend"||!no&&Va(e,t)?(e=ja(),wi=Xl=Zt=null,Fn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=i}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=Xa(n)}}function Za(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Za(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function ba(){for(var e=window,t=oi();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=oi(e.document)}return t}function lo(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Tp(e){var t=ba(),n=e.focusedElem,i=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Za(n.ownerDocument.documentElement,n)){if(i!==null&&lo(n)){if(t=i.start,e=i.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var o=n.textContent.length,a=Math.min(i.start,o);i=i.end===void 0?a:Math.min(i.end,o),!e.extend&&a>i&&(o=i,i=a,a=o),o=Ja(n,a);var f=Ja(n,i);o&&f&&(e.rangeCount!==1||e.anchorNode!==o.node||e.anchorOffset!==o.offset||e.focusNode!==f.node||e.focusOffset!==f.offset)&&(t=t.createRange(),t.setStart(o.node,o.offset),e.removeAllRanges(),a>i?(e.addRange(t),e.extend(f.node,f.offset)):(t.setEnd(f.node,f.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Vn=null,oo=null,Or=null,so=!1;function eu(e,t,n){var i=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;so||Vn==null||Vn!==oi(i)||(i=Vn,"selectionStart"in i&&lo(i)?i={start:i.selectionStart,end:i.selectionEnd}:(i=(i.ownerDocument&&i.ownerDocument.defaultView||window).getSelection(),i={anchorNode:i.anchorNode,anchorOffset:i.anchorOffset,focusNode:i.focusNode,focusOffset:i.focusOffset}),Or&&Ir(Or,i)||(Or=i,i=Ni(oo,"onSelect"),0Gn||(e.current=So[Gn],So[Gn]=null,Gn--)}function ve(e,t){Gn++,So[Gn]=e.current,e.current=t}var nn={},Ve=tn(nn),Ke=tn(!1),kn=nn;function qn(e,t){var n=e.type.contextTypes;if(!n)return nn;var i=e.stateNode;if(i&&i.__reactInternalMemoizedUnmaskedChildContext===t)return i.__reactInternalMemoizedMaskedChildContext;var o={},a;for(a in n)o[a]=t[a];return i&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=o),o}function Xe(e){return e=e.childContextTypes,e!=null}function Li(){we(Ke),we(Ve)}function hu(e,t,n){if(Ve.current!==nn)throw Error(s(168));ve(Ve,t),ve(Ke,n)}function vu(e,t,n){var i=e.stateNode;if(t=t.childContextTypes,typeof i.getChildContext!="function")return n;i=i.getChildContext();for(var o in i)if(!(o in t))throw Error(s(108,he(e)||"Unknown",o));return V({},n,i)}function Ii(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||nn,kn=Ve.current,ve(Ve,e),ve(Ke,Ke.current),!0}function yu(e,t,n){var i=e.stateNode;if(!i)throw Error(s(169));n?(e=vu(e,t,kn),i.__reactInternalMemoizedMergedChildContext=e,we(Ke),we(Ve),ve(Ve,e)):we(Ke),ve(Ke,n)}var Mt=null,Oi=!1,Eo=!1;function gu(e){Mt===null?Mt=[e]:Mt.push(e)}function Up(e){Oi=!0,gu(e)}function rn(){if(!Eo&&Mt!==null){Eo=!0;var e=0,t=de;try{var n=Mt;for(de=1;e>=f,o-=f,Dt=1<<32-gt(t)+o|n<Z?($e=X,X=null):$e=X.sibling;var ce=A(E,X,C[Z],j);if(ce===null){X===null&&(X=$e);break}e&&X&&ce.alternate===null&&t(E,X),g=a(ce,g,Z),K===null?Q=ce:K.sibling=ce,K=ce,X=$e}if(Z===C.length)return n(E,X),Ee&&_n(E,Z),Q;if(X===null){for(;ZZ?($e=X,X=null):$e=X.sibling;var pn=A(E,X,ce.value,j);if(pn===null){X===null&&(X=$e);break}e&&X&&pn.alternate===null&&t(E,X),g=a(pn,g,Z),K===null?Q=pn:K.sibling=pn,K=pn,X=$e}if(ce.done)return n(E,X),Ee&&_n(E,Z),Q;if(X===null){for(;!ce.done;Z++,ce=C.next())ce=O(E,ce.value,j),ce!==null&&(g=a(ce,g,Z),K===null?Q=ce:K.sibling=ce,K=ce);return Ee&&_n(E,Z),Q}for(X=i(E,X);!ce.done;Z++,ce=C.next())ce=U(X,E,Z,ce.value,j),ce!==null&&(e&&ce.alternate!==null&&X.delete(ce.key===null?Z:ce.key),g=a(ce,g,Z),K===null?Q=ce:K.sibling=ce,K=ce);return e&&X.forEach(function(Sm){return t(E,Sm)}),Ee&&_n(E,Z),Q}function Pe(E,g,C,j){if(typeof C=="object"&&C!==null&&C.type===te&&C.key===null&&(C=C.props.children),typeof C=="object"&&C!==null){switch(C.$$typeof){case b:e:{for(var Q=C.key,K=g;K!==null;){if(K.key===Q){if(Q=C.type,Q===te){if(K.tag===7){n(E,K.sibling),g=o(K,C.props.children),g.return=E,E=g;break e}}else if(K.elementType===Q||typeof Q=="object"&&Q!==null&&Q.$$typeof===qe&&Cu(Q)===K.type){n(E,K.sibling),g=o(K,C.props),g.ref=Br(E,K,C),g.return=E,E=g;break e}n(E,K);break}else t(E,K);K=K.sibling}C.type===te?(g=On(C.props.children,E.mode,j,C.key),g.return=E,E=g):(j=sl(C.type,C.key,C.props,null,E.mode,j),j.ref=Br(E,g,C),j.return=E,E=j)}return f(E);case ee:e:{for(K=C.key;g!==null;){if(g.key===K)if(g.tag===4&&g.stateNode.containerInfo===C.containerInfo&&g.stateNode.implementation===C.implementation){n(E,g.sibling),g=o(g,C.children||[]),g.return=E,E=g;break e}else{n(E,g);break}else t(E,g);g=g.sibling}g=gs(C,E.mode,j),g.return=E,E=g}return f(E);case qe:return K=C._init,Pe(E,g,K(C._payload),j)}if(mr(C))return W(E,g,C,j);if(J(C))return H(E,g,C,j);zi(E,C)}return typeof C=="string"&&C!==""||typeof C=="number"?(C=""+C,g!==null&&g.tag===6?(n(E,g.sibling),g=o(g,C),g.return=E,E=g):(n(E,g),g=ys(C,E.mode,j),g.return=E,E=g),f(E)):n(E,g)}return Pe}var Zn=_u(!0),Ru=_u(!1),$i=tn(null),Bi=null,bn=null,No=null;function To(){No=bn=Bi=null}function Po(e){var t=$i.current;we($i),e._currentValue=t}function Ao(e,t,n){for(;e!==null;){var i=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,i!==null&&(i.childLanes|=t)):i!==null&&(i.childLanes&t)!==t&&(i.childLanes|=t),e===n)break;e=e.return}}function er(e,t){Bi=e,No=bn=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(Je=!0),e.firstContext=null)}function pt(e){var t=e._currentValue;if(No!==e)if(e={context:e,memoizedValue:t,next:null},bn===null){if(Bi===null)throw Error(s(308));bn=e,Bi.dependencies={lanes:0,firstContext:e}}else bn=bn.next=e;return t}var Rn=null;function Lo(e){Rn===null?Rn=[e]:Rn.push(e)}function Nu(e,t,n,i){var o=t.interleaved;return o===null?(n.next=n,Lo(t)):(n.next=o.next,o.next=n),t.interleaved=n,$t(e,i)}function $t(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var ln=!1;function Io(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Tu(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Bt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function on(e,t,n){var i=e.updateQueue;if(i===null)return null;if(i=i.shared,(se&2)!==0){var o=i.pending;return o===null?t.next=t:(t.next=o.next,o.next=t),i.pending=t,$t(e,n)}return o=i.interleaved,o===null?(t.next=t,Lo(i)):(t.next=o.next,o.next=t),i.interleaved=t,$t(e,n)}function Ui(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var i=t.lanes;i&=e.pendingLanes,n|=i,t.lanes=n,Ql(e,n)}}function Pu(e,t){var n=e.updateQueue,i=e.alternate;if(i!==null&&(i=i.updateQueue,n===i)){var o=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var f={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};a===null?o=a=f:a=a.next=f,n=n.next}while(n!==null);a===null?o=a=t:a=a.next=t}else o=a=t;n={baseState:i.baseState,firstBaseUpdate:o,lastBaseUpdate:a,shared:i.shared,effects:i.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function Fi(e,t,n,i){var o=e.updateQueue;ln=!1;var a=o.firstBaseUpdate,f=o.lastBaseUpdate,h=o.shared.pending;if(h!==null){o.shared.pending=null;var v=h,_=v.next;v.next=null,f===null?a=_:f.next=_,f=v;var I=e.alternate;I!==null&&(I=I.updateQueue,h=I.lastBaseUpdate,h!==f&&(h===null?I.firstBaseUpdate=_:h.next=_,I.lastBaseUpdate=v))}if(a!==null){var O=o.baseState;f=0,I=_=v=null,h=a;do{var A=h.lane,U=h.eventTime;if((i&A)===A){I!==null&&(I=I.next={eventTime:U,lane:0,tag:h.tag,payload:h.payload,callback:h.callback,next:null});e:{var W=e,H=h;switch(A=t,U=n,H.tag){case 1:if(W=H.payload,typeof W=="function"){O=W.call(U,O,A);break e}O=W;break e;case 3:W.flags=W.flags&-65537|128;case 0:if(W=H.payload,A=typeof W=="function"?W.call(U,O,A):W,A==null)break e;O=V({},O,A);break e;case 2:ln=!0}}h.callback!==null&&h.lane!==0&&(e.flags|=64,A=o.effects,A===null?o.effects=[h]:A.push(h))}else U={eventTime:U,lane:A,tag:h.tag,payload:h.payload,callback:h.callback,next:null},I===null?(_=I=U,v=O):I=I.next=U,f|=A;if(h=h.next,h===null){if(h=o.shared.pending,h===null)break;A=h,h=A.next,A.next=null,o.lastBaseUpdate=A,o.shared.pending=null}}while(!0);if(I===null&&(v=O),o.baseState=v,o.firstBaseUpdate=_,o.lastBaseUpdate=I,t=o.shared.interleaved,t!==null){o=t;do f|=o.lane,o=o.next;while(o!==t)}else a===null&&(o.shared.lanes=0);Pn|=f,e.lanes=f,e.memoizedState=O}}function Au(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var i=zo.transition;zo.transition={};try{e(!1),t()}finally{de=n,zo.transition=i}}function Ku(){return mt().memoizedState}function Hp(e,t,n){var i=cn(e);if(n={lane:i,action:n,hasEagerState:!1,eagerState:null,next:null},Xu(e))Ju(t,n);else if(n=Nu(e,t,n,i),n!==null){var o=Ge();Ct(n,e,i,o),Zu(n,t,i)}}function Qp(e,t,n){var i=cn(e),o={lane:i,action:n,hasEagerState:!1,eagerState:null,next:null};if(Xu(e))Ju(t,o);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var f=t.lastRenderedState,h=a(f,n);if(o.hasEagerState=!0,o.eagerState=h,wt(h,f)){var v=t.interleaved;v===null?(o.next=o,Lo(t)):(o.next=v.next,v.next=o),t.interleaved=o;return}}catch{}n=Nu(e,t,o,i),n!==null&&(o=Ge(),Ct(n,e,i,o),Zu(n,t,i))}}function Xu(e){var t=e.alternate;return e===ke||t!==null&&t===ke}function Ju(e,t){Wr=Hi=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Zu(e,t,n){if((n&4194240)!==0){var i=t.lanes;i&=e.pendingLanes,n|=i,t.lanes=n,Ql(e,n)}}var Gi={readContext:pt,useCallback:We,useContext:We,useEffect:We,useImperativeHandle:We,useInsertionEffect:We,useLayoutEffect:We,useMemo:We,useReducer:We,useRef:We,useState:We,useDebugValue:We,useDeferredValue:We,useTransition:We,useMutableSource:We,useSyncExternalStore:We,useId:We,unstable_isNewReconciler:!1},Yp={readContext:pt,useCallback:function(e,t){return Lt().memoizedState=[e,t===void 0?null:t],e},useContext:pt,useEffect:Fu,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Qi(4194308,4,Hu.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Qi(4194308,4,e,t)},useInsertionEffect:function(e,t){return Qi(4,2,e,t)},useMemo:function(e,t){var n=Lt();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var i=Lt();return t=n!==void 0?n(t):t,i.memoizedState=i.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},i.queue=e,e=e.dispatch=Hp.bind(null,ke,e),[i.memoizedState,e]},useRef:function(e){var t=Lt();return e={current:e},t.memoizedState=e},useState:Bu,useDebugValue:Ho,useDeferredValue:function(e){return Lt().memoizedState=e},useTransition:function(){var e=Bu(!1),t=e[0];return e=Wp.bind(null,e[1]),Lt().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var i=ke,o=Lt();if(Ee){if(n===void 0)throw Error(s(407));n=n()}else{if(n=t(),ze===null)throw Error(s(349));(Tn&30)!==0||ju(i,t,n)}o.memoizedState=n;var a={value:n,getSnapshot:t};return o.queue=a,Fu(Du.bind(null,i,a,e),[e]),i.flags|=2048,Yr(9,Mu.bind(null,i,a,n,t),void 0,null),n},useId:function(){var e=Lt(),t=ze.identifierPrefix;if(Ee){var n=zt,i=Dt;n=(i&~(1<<32-gt(i)-1)).toString(32)+n,t=":"+t+"R"+n,n=Hr++,0<\/script>",e=e.removeChild(e.firstChild)):typeof i.is=="string"?e=f.createElement(n,{is:i.is}):(e=f.createElement(n),n==="select"&&(f=e,i.multiple?f.multiple=!0:i.size&&(f.size=i.size))):e=f.createElementNS(e,n),e[Pt]=t,e[zr]=i,gc(e,t,!1,!1),t.stateNode=e;e:{switch(f=Ml(n,i),n){case"dialog":ge("cancel",e),ge("close",e),o=i;break;case"iframe":case"object":case"embed":ge("load",e),o=i;break;case"video":case"audio":for(o=0;olr&&(t.flags|=128,i=!0,Gr(a,!1),t.lanes=4194304)}else{if(!i)if(e=Vi(f),e!==null){if(t.flags|=128,i=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),Gr(a,!0),a.tail===null&&a.tailMode==="hidden"&&!f.alternate&&!Ee)return He(t),null}else 2*Te()-a.renderingStartTime>lr&&n!==1073741824&&(t.flags|=128,i=!0,Gr(a,!1),t.lanes=4194304);a.isBackwards?(f.sibling=t.child,t.child=f):(n=a.last,n!==null?n.sibling=f:t.child=f,a.last=f)}return a.tail!==null?(t=a.tail,a.rendering=t,a.tail=t.sibling,a.renderingStartTime=Te(),t.sibling=null,n=xe.current,ve(xe,i?n&1|2:n&1),t):(He(t),null);case 22:case 23:return ms(),i=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==i&&(t.flags|=8192),i&&(t.mode&1)!==0?(at&1073741824)!==0&&(He(t),t.subtreeFlags&6&&(t.flags|=8192)):He(t),null;case 24:return null;case 25:return null}throw Error(s(156,t.tag))}function em(e,t){switch(ko(t),t.tag){case 1:return Xe(t.type)&&Li(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return tr(),we(Ke),we(Ve),Do(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 5:return jo(t),null;case 13:if(we(xe),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(s(340));Jn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return we(xe),null;case 4:return tr(),null;case 10:return Po(t.type._context),null;case 22:case 23:return ms(),null;case 24:return null;default:return null}}var Ji=!1,Qe=!1,tm=typeof WeakSet=="function"?WeakSet:Set,F=null;function rr(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(i){Ne(e,t,i)}else n.current=null}function ns(e,t,n){try{n()}catch(i){Ne(e,t,i)}}var Ec=!1;function nm(e,t){if(mo=yi,e=ba(),lo(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var i=n.getSelection&&n.getSelection();if(i&&i.rangeCount!==0){n=i.anchorNode;var o=i.anchorOffset,a=i.focusNode;i=i.focusOffset;try{n.nodeType,a.nodeType}catch{n=null;break e}var f=0,h=-1,v=-1,_=0,I=0,O=e,A=null;t:for(;;){for(var U;O!==n||o!==0&&O.nodeType!==3||(h=f+o),O!==a||i!==0&&O.nodeType!==3||(v=f+i),O.nodeType===3&&(f+=O.nodeValue.length),(U=O.firstChild)!==null;)A=O,O=U;for(;;){if(O===e)break t;if(A===n&&++_===o&&(h=f),A===a&&++I===i&&(v=f),(U=O.nextSibling)!==null)break;O=A,A=O.parentNode}O=U}n=h===-1||v===-1?null:{start:h,end:v}}else n=null}n=n||{start:0,end:0}}else n=null;for(ho={focusedElem:e,selectionRange:n},yi=!1,F=t;F!==null;)if(t=F,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,F=e;else for(;F!==null;){t=F;try{var W=t.alternate;if((t.flags&1024)!==0)switch(t.tag){case 0:case 11:case 15:break;case 1:if(W!==null){var H=W.memoizedProps,Pe=W.memoizedState,E=t.stateNode,g=E.getSnapshotBeforeUpdate(t.elementType===t.type?H:Et(t.type,H),Pe);E.__reactInternalSnapshotBeforeUpdate=g}break;case 3:var C=t.stateNode.containerInfo;C.nodeType===1?C.textContent="":C.nodeType===9&&C.documentElement&&C.removeChild(C.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(s(163))}}catch(j){Ne(t,t.return,j)}if(e=t.sibling,e!==null){e.return=t.return,F=e;break}F=t.return}return W=Ec,Ec=!1,W}function qr(e,t,n){var i=t.updateQueue;if(i=i!==null?i.lastEffect:null,i!==null){var o=i=i.next;do{if((o.tag&e)===e){var a=o.destroy;o.destroy=void 0,a!==void 0&&ns(t,n,a)}o=o.next}while(o!==i)}}function Zi(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var i=n.create;n.destroy=i()}n=n.next}while(n!==t)}}function rs(e){var t=e.ref;if(t!==null){var n=e.stateNode;e.tag,e=n,typeof t=="function"?t(e):t.current=e}}function xc(e){var t=e.alternate;t!==null&&(e.alternate=null,xc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Pt],delete t[zr],delete t[wo],delete t[$p],delete t[Bp])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function kc(e){return e.tag===5||e.tag===3||e.tag===4}function Cc(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||kc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function is(e,t,n){var i=e.tag;if(i===5||i===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Pi));else if(i!==4&&(e=e.child,e!==null))for(is(e,t,n),e=e.sibling;e!==null;)is(e,t,n),e=e.sibling}function ls(e,t,n){var i=e.tag;if(i===5||i===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(i!==4&&(e=e.child,e!==null))for(ls(e,t,n),e=e.sibling;e!==null;)ls(e,t,n),e=e.sibling}var Ue=null,xt=!1;function sn(e,t,n){for(n=n.child;n!==null;)_c(e,t,n),n=n.sibling}function _c(e,t,n){if(Tt&&typeof Tt.onCommitFiberUnmount=="function")try{Tt.onCommitFiberUnmount(fi,n)}catch{}switch(n.tag){case 5:Qe||rr(n,t);case 6:var i=Ue,o=xt;Ue=null,sn(e,t,n),Ue=i,xt=o,Ue!==null&&(xt?(e=Ue,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):Ue.removeChild(n.stateNode));break;case 18:Ue!==null&&(xt?(e=Ue,n=n.stateNode,e.nodeType===8?go(e.parentNode,n):e.nodeType===1&&go(e,n),Rr(e)):go(Ue,n.stateNode));break;case 4:i=Ue,o=xt,Ue=n.stateNode.containerInfo,xt=!0,sn(e,t,n),Ue=i,xt=o;break;case 0:case 11:case 14:case 15:if(!Qe&&(i=n.updateQueue,i!==null&&(i=i.lastEffect,i!==null))){o=i=i.next;do{var a=o,f=a.destroy;a=a.tag,f!==void 0&&((a&2)!==0||(a&4)!==0)&&ns(n,t,f),o=o.next}while(o!==i)}sn(e,t,n);break;case 1:if(!Qe&&(rr(n,t),i=n.stateNode,typeof i.componentWillUnmount=="function"))try{i.props=n.memoizedProps,i.state=n.memoizedState,i.componentWillUnmount()}catch(h){Ne(n,t,h)}sn(e,t,n);break;case 21:sn(e,t,n);break;case 22:n.mode&1?(Qe=(i=Qe)||n.memoizedState!==null,sn(e,t,n),Qe=i):sn(e,t,n);break;default:sn(e,t,n)}}function Rc(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new tm),t.forEach(function(i){var o=fm.bind(null,e,i);n.has(i)||(n.add(i),i.then(o,o))})}}function kt(e,t){var n=t.deletions;if(n!==null)for(var i=0;io&&(o=f),i&=~a}if(i=o,i=Te()-i,i=(120>i?120:480>i?480:1080>i?1080:1920>i?1920:3e3>i?3e3:4320>i?4320:1960*im(i/1960))-i,10e?16:e,un===null)var i=!1;else{if(e=un,un=null,rl=0,(se&6)!==0)throw Error(s(331));var o=se;for(se|=4,F=e.current;F!==null;){var a=F,f=a.child;if((F.flags&16)!==0){var h=a.deletions;if(h!==null){for(var v=0;vTe()-as?Ln(e,0):ss|=n),be(e,t)}function Bc(e,t){t===0&&((e.mode&1)===0?t=1:(t=pi,pi<<=1,(pi&130023424)===0&&(pi=4194304)));var n=Ge();e=$t(e,t),e!==null&&(Er(e,t,n),be(e,n))}function cm(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Bc(e,n)}function fm(e,t){var n=0;switch(e.tag){case 13:var i=e.stateNode,o=e.memoizedState;o!==null&&(n=o.retryLane);break;case 19:i=e.stateNode;break;default:throw Error(s(314))}i!==null&&i.delete(t),Bc(e,n)}var Uc;Uc=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||Ke.current)Je=!0;else{if((e.lanes&n)===0&&(t.flags&128)===0)return Je=!1,Zp(e,t,n);Je=(e.flags&131072)!==0}else Je=!1,Ee&&(t.flags&1048576)!==0&&wu(t,Mi,t.index);switch(t.lanes=0,t.tag){case 2:var i=t.type;Xi(e,t),e=t.pendingProps;var o=qn(t,Ve.current);er(t,n),o=Bo(null,t,i,e,o,n);var a=Uo();return t.flags|=1,typeof o=="object"&&o!==null&&typeof o.render=="function"&&o.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,Xe(i)?(a=!0,Ii(t)):a=!1,t.memoizedState=o.state!==null&&o.state!==void 0?o.state:null,Io(t),o.updater=qi,t.stateNode=o,o._reactInternals=t,Yo(t,i,e,n),t=Xo(null,t,i,!0,a,n)):(t.tag=0,Ee&&a&&xo(t),Ye(null,t,o,n),t=t.child),t;case 16:i=t.elementType;e:{switch(Xi(e,t),e=t.pendingProps,o=i._init,i=o(i._payload),t.type=i,o=t.tag=pm(i),e=Et(i,e),o){case 0:t=Ko(null,t,i,e,n);break e;case 1:t=dc(null,t,i,e,n);break e;case 11:t=sc(null,t,i,e,n);break e;case 14:t=ac(null,t,i,Et(i.type,e),n);break e}throw Error(s(306,i,""))}return t;case 0:return i=t.type,o=t.pendingProps,o=t.elementType===i?o:Et(i,o),Ko(e,t,i,o,n);case 1:return i=t.type,o=t.pendingProps,o=t.elementType===i?o:Et(i,o),dc(e,t,i,o,n);case 3:e:{if(pc(t),e===null)throw Error(s(387));i=t.pendingProps,a=t.memoizedState,o=a.element,Tu(e,t),Fi(t,i,null,n);var f=t.memoizedState;if(i=f.element,a.isDehydrated)if(a={element:i,isDehydrated:!1,cache:f.cache,pendingSuspenseBoundaries:f.pendingSuspenseBoundaries,transitions:f.transitions},t.updateQueue.baseState=a,t.memoizedState=a,t.flags&256){o=nr(Error(s(423)),t),t=mc(e,t,i,n,o);break e}else if(i!==o){o=nr(Error(s(424)),t),t=mc(e,t,i,n,o);break e}else for(st=en(t.stateNode.containerInfo.firstChild),ot=t,Ee=!0,St=null,n=Ru(t,null,i,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(Jn(),i===o){t=Ut(e,t,n);break e}Ye(e,t,i,n)}t=t.child}return t;case 5:return Lu(t),e===null&&_o(t),i=t.type,o=t.pendingProps,a=e!==null?e.memoizedProps:null,f=o.children,vo(i,o)?f=null:a!==null&&vo(i,a)&&(t.flags|=32),fc(e,t),Ye(e,t,f,n),t.child;case 6:return e===null&&_o(t),null;case 13:return hc(e,t,n);case 4:return Oo(t,t.stateNode.containerInfo),i=t.pendingProps,e===null?t.child=Zn(t,null,i,n):Ye(e,t,i,n),t.child;case 11:return i=t.type,o=t.pendingProps,o=t.elementType===i?o:Et(i,o),sc(e,t,i,o,n);case 7:return Ye(e,t,t.pendingProps,n),t.child;case 8:return Ye(e,t,t.pendingProps.children,n),t.child;case 12:return Ye(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(i=t.type._context,o=t.pendingProps,a=t.memoizedProps,f=o.value,ve($i,i._currentValue),i._currentValue=f,a!==null)if(wt(a.value,f)){if(a.children===o.children&&!Ke.current){t=Ut(e,t,n);break e}}else for(a=t.child,a!==null&&(a.return=t);a!==null;){var h=a.dependencies;if(h!==null){f=a.child;for(var v=h.firstContext;v!==null;){if(v.context===i){if(a.tag===1){v=Bt(-1,n&-n),v.tag=2;var _=a.updateQueue;if(_!==null){_=_.shared;var I=_.pending;I===null?v.next=v:(v.next=I.next,I.next=v),_.pending=v}}a.lanes|=n,v=a.alternate,v!==null&&(v.lanes|=n),Ao(a.return,n,t),h.lanes|=n;break}v=v.next}}else if(a.tag===10)f=a.type===t.type?null:a.child;else if(a.tag===18){if(f=a.return,f===null)throw Error(s(341));f.lanes|=n,h=f.alternate,h!==null&&(h.lanes|=n),Ao(f,n,t),f=a.sibling}else f=a.child;if(f!==null)f.return=a;else for(f=a;f!==null;){if(f===t){f=null;break}if(a=f.sibling,a!==null){a.return=f.return,f=a;break}f=f.return}a=f}Ye(e,t,o.children,n),t=t.child}return t;case 9:return o=t.type,i=t.pendingProps.children,er(t,n),o=pt(o),i=i(o),t.flags|=1,Ye(e,t,i,n),t.child;case 14:return i=t.type,o=Et(i,t.pendingProps),o=Et(i.type,o),ac(e,t,i,o,n);case 15:return uc(e,t,t.type,t.pendingProps,n);case 17:return i=t.type,o=t.pendingProps,o=t.elementType===i?o:Et(i,o),Xi(e,t),t.tag=1,Xe(i)?(e=!0,Ii(t)):e=!1,er(t,n),ec(t,i,o),Yo(t,i,o,n),Xo(null,t,i,!0,e,n);case 19:return yc(e,t,n);case 22:return cc(e,t,n)}throw Error(s(156,t.tag))};function Fc(e,t){return Sa(e,t)}function dm(e,t,n,i){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=i,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function vt(e,t,n,i){return new dm(e,t,n,i)}function vs(e){return e=e.prototype,!(!e||!e.isReactComponent)}function pm(e){if(typeof e=="function")return vs(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Le)return 11;if(e===rt)return 14}return 2}function dn(e,t){var n=e.alternate;return n===null?(n=vt(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function sl(e,t,n,i,o,a){var f=2;if(i=e,typeof e=="function")vs(e)&&(f=1);else if(typeof e=="string")f=5;else e:switch(e){case te:return On(n.children,o,a,t);case ne:f=8,o|=8;break;case ye:return e=vt(12,n,t,o|2),e.elementType=ye,e.lanes=a,e;case Me:return e=vt(13,n,t,o),e.elementType=Me,e.lanes=a,e;case Ie:return e=vt(19,n,t,o),e.elementType=Ie,e.lanes=a,e;case Re:return al(n,o,a,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case oe:f=10;break e;case _e:f=9;break e;case Le:f=11;break e;case rt:f=14;break e;case qe:f=16,i=null;break e}throw Error(s(130,e==null?e:typeof e,""))}return t=vt(f,n,t,o),t.elementType=e,t.type=i,t.lanes=a,t}function On(e,t,n,i){return e=vt(7,e,i,t),e.lanes=n,e}function al(e,t,n,i){return e=vt(22,e,i,t),e.elementType=Re,e.lanes=n,e.stateNode={isHidden:!1},e}function ys(e,t,n){return e=vt(6,e,null,t),e.lanes=n,e}function gs(e,t,n){return t=vt(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function mm(e,t,n,i,o){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Hl(0),this.expirationTimes=Hl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Hl(0),this.identifierPrefix=i,this.onRecoverableError=o,this.mutableSourceEagerHydrationData=null}function ws(e,t,n,i,o,a,f,h,v){return e=new mm(e,t,n,h,v),t===1?(t=1,a===!0&&(t|=8)):t=0,a=vt(3,null,null,t),e.current=a,a.stateNode=e,a.memoizedState={element:i,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Io(a),e}function hm(e,t,n){var i=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(r)}catch(l){console.error(l)}}return r(),_s.exports=Tm(),_s.exports}var tf;function Pm(){if(tf)return hl;tf=1;var r=Lf();return hl.createRoot=r.createRoot,hl.hydrateRoot=r.hydrateRoot,hl}var Am=Pm();const Lm=Pf(Am);Lf();function ti(){return ti=Object.assign?Object.assign.bind():function(r){for(var l=1;l"u")throw new Error(l)}function Hs(r,l){if(!r){typeof console<"u"&&console.warn(l);try{throw new Error(l)}catch{}}}function Om(){return Math.random().toString(36).substr(2,8)}function rf(r,l){return{usr:r.state,key:r.key,idx:l}}function Os(r,l,s,u){return s===void 0&&(s=null),ti({pathname:typeof r=="string"?r:r.pathname,search:"",hash:""},typeof l=="string"?dr(l):l,{state:s,key:l&&l.key||u||Om()})}function wl(r){let{pathname:l="/",search:s="",hash:u=""}=r;return s&&s!=="?"&&(l+=s.charAt(0)==="?"?s:"?"+s),u&&u!=="#"&&(l+=u.charAt(0)==="#"?u:"#"+u),l}function dr(r){let l={};if(r){let s=r.indexOf("#");s>=0&&(l.hash=r.substr(s),r=r.substr(0,s));let u=r.indexOf("?");u>=0&&(l.search=r.substr(u),r=r.substr(0,u)),r&&(l.pathname=r)}return l}function jm(r,l,s,u){u===void 0&&(u={});let{window:c=document.defaultView,v5Compat:d=!1}=u,p=c.history,m=hn.Pop,y=null,x=k();x==null&&(x=0,p.replaceState(ti({},p.state,{idx:x}),""));function k(){return(p.state||{idx:null}).idx}function R(){m=hn.Pop;let P=k(),D=P==null?null:P-x;x=P,y&&y({action:m,location:M.location,delta:D})}function T(P,D){m=hn.Push;let G=Os(M.location,P,D);x=k()+1;let Y=rf(G,x),q=M.createHref(G);try{p.pushState(Y,"",q)}catch(b){if(b instanceof DOMException&&b.name==="DataCloneError")throw b;c.location.assign(q)}d&&y&&y({action:m,location:M.location,delta:1})}function $(P,D){m=hn.Replace;let G=Os(M.location,P,D);x=k();let Y=rf(G,x),q=M.createHref(G);p.replaceState(Y,"",q),d&&y&&y({action:m,location:M.location,delta:0})}function z(P){let D=c.location.origin!=="null"?c.location.origin:c.location.href,G=typeof P=="string"?P:wl(P);return G=G.replace(/ $/,"%20"),Ce(D,"No window.location.(origin|href) available to create URL for href: "+G),new URL(G,D)}let M={get action(){return m},get location(){return r(c,p)},listen(P){if(y)throw new Error("A history only accepts one active listener");return c.addEventListener(nf,R),y=P,()=>{c.removeEventListener(nf,R),y=null}},createHref(P){return l(c,P)},createURL:z,encodeLocation(P){let D=z(P);return{pathname:D.pathname,search:D.search,hash:D.hash}},push:T,replace:$,go(P){return p.go(P)}};return M}var lf;(function(r){r.data="data",r.deferred="deferred",r.redirect="redirect",r.error="error"})(lf||(lf={}));function Mm(r,l,s){return s===void 0&&(s="/"),Dm(r,l,s)}function Dm(r,l,s,u){let c=typeof l=="string"?dr(l):l,d=cr(c.pathname||"/",s);if(d==null)return null;let p=If(r);zm(p);let m=null,y=qm(d);for(let x=0;m==null&&x{let y={relativePath:m===void 0?d.path||"":m,caseSensitive:d.caseSensitive===!0,childrenIndex:p,route:d};y.relativePath.startsWith("/")&&(Ce(y.relativePath.startsWith(u),'Absolute route path "'+y.relativePath+'" nested under path '+('"'+u+'" is not valid. An absolute child route path ')+"must start with the combined path of all its parent routes."),y.relativePath=y.relativePath.slice(u.length));let x=yn([u,y.relativePath]),k=s.concat(y);d.children&&d.children.length>0&&(Ce(d.index!==!0,"Index routes must not have child routes. Please remove "+('all child routes from route path "'+x+'".')),If(d.children,l,k,x)),!(d.path==null&&!d.index)&&l.push({path:x,score:Hm(x,d.index),routesMeta:k})};return r.forEach((d,p)=>{var m;if(d.path===""||!((m=d.path)!=null&&m.includes("?")))c(d,p);else for(let y of Of(d.path))c(d,p,y)}),l}function Of(r){let l=r.split("/");if(l.length===0)return[];let[s,...u]=l,c=s.endsWith("?"),d=s.replace(/\?$/,"");if(u.length===0)return c?[d,""]:[d];let p=Of(u.join("/")),m=[];return m.push(...p.map(y=>y===""?d:[d,y].join("/"))),c&&m.push(...p),m.map(y=>r.startsWith("/")&&y===""?"/":y)}function zm(r){r.sort((l,s)=>l.score!==s.score?s.score-l.score:Qm(l.routesMeta.map(u=>u.childrenIndex),s.routesMeta.map(u=>u.childrenIndex)))}const $m=/^:[\w-]+$/,Bm=3,Um=2,Fm=1,Vm=10,Wm=-2,of=r=>r==="*";function Hm(r,l){let s=r.split("/"),u=s.length;return s.some(of)&&(u+=Wm),l&&(u+=Um),s.filter(c=>!of(c)).reduce((c,d)=>c+($m.test(d)?Bm:d===""?Fm:Vm),u)}function Qm(r,l){return r.length===l.length&&r.slice(0,-1).every((u,c)=>u===l[c])?r[r.length-1]-l[l.length-1]:0}function Ym(r,l,s){let{routesMeta:u}=r,c={},d="/",p=[];for(let m=0;m{let{paramName:T,isOptional:$}=k;if(T==="*"){let M=m[R]||"";p=d.slice(0,d.length-M.length).replace(/(.)\/+$/,"$1")}const z=m[R];return $&&!z?x[T]=void 0:x[T]=(z||"").replace(/%2F/g,"/"),x},{}),pathname:d,pathnameBase:p,pattern:r}}function Gm(r,l,s){l===void 0&&(l=!1),s===void 0&&(s=!0),Hs(r==="*"||!r.endsWith("*")||r.endsWith("/*"),'Route path "'+r+'" will be treated as if it were '+('"'+r.replace(/\*$/,"/*")+'" because the `*` character must ')+"always follow a `/` in the pattern. To get rid of this warning, "+('please change the route path to "'+r.replace(/\*$/,"/*")+'".'));let u=[],c="^"+r.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(p,m,y)=>(u.push({paramName:m,isOptional:y!=null}),y?"/?([^\\/]+)?":"/([^\\/]+)"));return r.endsWith("*")?(u.push({paramName:"*"}),c+=r==="*"||r==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):s?c+="\\/*$":r!==""&&r!=="/"&&(c+="(?:(?=\\/|$))"),[new RegExp(c,l?void 0:"i"),u]}function qm(r){try{return r.split("/").map(l=>decodeURIComponent(l).replace(/\//g,"%2F")).join("/")}catch(l){return Hs(!1,'The URL path "'+r+'" could not be decoded because it is is a malformed URL segment. This is probably due to a bad percent '+("encoding ("+l+").")),r}}function cr(r,l){if(l==="/")return r;if(!r.toLowerCase().startsWith(l.toLowerCase()))return null;let s=l.endsWith("/")?l.length-1:l.length,u=r.charAt(s);return u&&u!=="/"?null:r.slice(s)||"/"}const Km=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,Xm=r=>Km.test(r);function Jm(r,l){l===void 0&&(l="/");let{pathname:s,search:u="",hash:c=""}=typeof r=="string"?dr(r):r,d;if(s)if(Xm(s))d=s;else{if(s.includes("//")){let p=s;s=jf(s),Hs(!1,"Pathnames cannot have embedded double slashes - normalizing "+(p+" -> "+s))}s.startsWith("/")?d=sf(s.substring(1),"/"):d=sf(s,l)}else d=l;return{pathname:d,search:eh(u),hash:th(c)}}function sf(r,l){let s=l.replace(/\/+$/,"").split("/");return r.split("/").forEach(c=>{c===".."?s.length>1&&s.pop():c!=="."&&s.push(c)}),s.length>1?s.join("/"):"/"}function Ts(r,l,s,u){return"Cannot include a '"+r+"' character in a manually specified "+("`to."+l+"` field ["+JSON.stringify(u)+"]. Please separate it out to the ")+("`to."+s+"` field. Alternatively you may provide the full path as ")+'a string in and the router will parse it for you.'}function Zm(r){return r.filter((l,s)=>s===0||l.route.path&&l.route.path.length>0)}function Qs(r,l){let s=Zm(r);return l?s.map((u,c)=>c===s.length-1?u.pathname:u.pathnameBase):s.map(u=>u.pathnameBase)}function Ys(r,l,s,u){u===void 0&&(u=!1);let c;typeof r=="string"?c=dr(r):(c=ti({},r),Ce(!c.pathname||!c.pathname.includes("?"),Ts("?","pathname","search",c)),Ce(!c.pathname||!c.pathname.includes("#"),Ts("#","pathname","hash",c)),Ce(!c.search||!c.search.includes("#"),Ts("#","search","hash",c)));let d=r===""||c.pathname==="",p=d?"/":c.pathname,m;if(p==null)m=s;else{let R=l.length-1;if(!u&&p.startsWith("..")){let T=p.split("/");for(;T[0]==="..";)T.shift(),R-=1;c.pathname=T.join("/")}m=R>=0?l[R]:"/"}let y=Jm(c,m),x=p&&p!=="/"&&p.endsWith("/"),k=(d||p===".")&&s.endsWith("/");return!y.pathname.endsWith("/")&&(x||k)&&(y.pathname+="/"),y}const jf=r=>r.replace(/\/\/+/g,"/"),yn=r=>jf(r.join("/")),bm=r=>r.replace(/\/+$/,"").replace(/^\/*/,"/"),eh=r=>!r||r==="?"?"":r.startsWith("?")?r:"?"+r,th=r=>!r||r==="#"?"":r.startsWith("#")?r:"#"+r;function nh(r){return r!=null&&typeof r.status=="number"&&typeof r.statusText=="string"&&typeof r.internal=="boolean"&&"data"in r}const Mf=["post","put","patch","delete"];new Set(Mf);const rh=["get",...Mf];new Set(rh);function ni(){return ni=Object.assign?Object.assign.bind():function(r){for(var l=1;l{m.current=!0}),w.useCallback(function(x,k){if(k===void 0&&(k={}),!m.current)return;if(typeof x=="number"){u.go(x);return}let R=Ys(x,JSON.parse(p),d,k.relative==="path");r==null&&l!=="/"&&(R.pathname=R.pathname==="/"?l:yn([l,R.pathname])),(k.replace?u.replace:u.push)(R,k.state,k)},[l,u,p,d,r])}function ww(){let{matches:r}=w.useContext(Ht),l=r[r.length-1];return l?l.params:{}}function kl(r,l){let{relative:s}=l===void 0?{}:l,{future:u}=w.useContext(Wt),{matches:c}=w.useContext(Ht),{pathname:d}=Qt(),p=JSON.stringify(Qs(c,u.v7_relativeSplatPath));return w.useMemo(()=>Ys(r,JSON.parse(p),d,s==="path"),[r,p,d,s])}function oh(r,l){return sh(r,l)}function sh(r,l,s,u){pr()||Ce(!1);let{navigator:c}=w.useContext(Wt),{matches:d}=w.useContext(Ht),p=d[d.length-1],m=p?p.params:{};p&&p.pathname;let y=p?p.pathnameBase:"/";p&&p.route;let x=Qt(),k;if(l){var R;let P=typeof l=="string"?dr(l):l;y==="/"||(R=P.pathname)!=null&&R.startsWith(y)||Ce(!1),k=P}else k=x;let T=k.pathname||"/",$=T;if(y!=="/"){let P=y.replace(/^\//,"").split("/");$="/"+T.replace(/^\//,"").split("/").slice(P.length).join("/")}let z=Mm(r,{pathname:$}),M=dh(z&&z.map(P=>Object.assign({},P,{params:Object.assign({},m,P.params),pathname:yn([y,c.encodeLocation?c.encodeLocation(P.pathname).pathname:P.pathname]),pathnameBase:P.pathnameBase==="/"?y:yn([y,c.encodeLocation?c.encodeLocation(P.pathnameBase).pathname:P.pathnameBase])})),d,s,u);return l&&M?w.createElement(xl.Provider,{value:{location:ni({pathname:"/",search:"",hash:"",state:null,key:"default"},k),navigationType:hn.Pop}},M):M}function ah(){let r=vh(),l=nh(r)?r.status+" "+r.statusText:r instanceof Error?r.message:JSON.stringify(r),s=r instanceof Error?r.stack:null,c={padding:"0.5rem",backgroundColor:"rgba(200,200,200, 0.5)"};return w.createElement(w.Fragment,null,w.createElement("h2",null,"Unexpected Application Error!"),w.createElement("h3",{style:{fontStyle:"italic"}},l),s?w.createElement("pre",{style:c},s):null,null)}const uh=w.createElement(ah,null);class ch extends w.Component{constructor(l){super(l),this.state={location:l.location,revalidation:l.revalidation,error:l.error}}static getDerivedStateFromError(l){return{error:l}}static getDerivedStateFromProps(l,s){return s.location!==l.location||s.revalidation!=="idle"&&l.revalidation==="idle"?{error:l.error,location:l.location,revalidation:l.revalidation}:{error:l.error!==void 0?l.error:s.error,location:s.location,revalidation:l.revalidation||s.revalidation}}componentDidCatch(l,s){console.error("React Router caught the following error during render",l,s)}render(){return this.state.error!==void 0?w.createElement(Ht.Provider,{value:this.props.routeContext},w.createElement(zf.Provider,{value:this.state.error,children:this.props.component})):this.props.children}}function fh(r){let{routeContext:l,match:s,children:u}=r,c=w.useContext(El);return c&&c.static&&c.staticContext&&(s.route.errorElement||s.route.ErrorBoundary)&&(c.staticContext._deepestRenderedBoundaryId=s.route.id),w.createElement(Ht.Provider,{value:l},u)}function dh(r,l,s,u){var c;if(l===void 0&&(l=[]),s===void 0&&(s=null),u===void 0&&(u=null),r==null){var d;if(!s)return null;if(s.errors)r=s.matches;else if((d=u)!=null&&d.v7_partialHydration&&l.length===0&&!s.initialized&&s.matches.length>0)r=s.matches;else return null}let p=r,m=(c=s)==null?void 0:c.errors;if(m!=null){let k=p.findIndex(R=>R.route.id&&m?.[R.route.id]!==void 0);k>=0||Ce(!1),p=p.slice(0,Math.min(p.length,k+1))}let y=!1,x=-1;if(s&&u&&u.v7_partialHydration)for(let k=0;k=0?p=p.slice(0,x+1):p=[p[0]];break}}}return p.reduceRight((k,R,T)=>{let $,z=!1,M=null,P=null;s&&($=m&&R.route.id?m[R.route.id]:void 0,M=R.route.errorElement||uh,y&&(x<0&&T===0?(gh("route-fallback"),z=!0,P=null):x===T&&(z=!0,P=R.route.hydrateFallbackElement||null)));let D=l.concat(p.slice(0,T+1)),G=()=>{let Y;return $?Y=M:z?Y=P:R.route.Component?Y=w.createElement(R.route.Component,null):R.route.element?Y=R.route.element:Y=k,w.createElement(fh,{match:R,routeContext:{outlet:k,matches:D,isDataRoute:s!=null},children:Y})};return s&&(R.route.ErrorBoundary||R.route.errorElement||T===0)?w.createElement(ch,{location:s.location,revalidation:s.revalidation,component:M,error:$,children:G(),routeContext:{outlet:null,matches:D,isDataRoute:!0}}):G()},null)}var Bf=(function(r){return r.UseBlocker="useBlocker",r.UseRevalidator="useRevalidator",r.UseNavigateStable="useNavigate",r})(Bf||{}),Uf=(function(r){return r.UseBlocker="useBlocker",r.UseLoaderData="useLoaderData",r.UseActionData="useActionData",r.UseRouteError="useRouteError",r.UseNavigation="useNavigation",r.UseRouteLoaderData="useRouteLoaderData",r.UseMatches="useMatches",r.UseRevalidator="useRevalidator",r.UseNavigateStable="useNavigate",r.UseRouteId="useRouteId",r})(Uf||{});function ph(r){let l=w.useContext(El);return l||Ce(!1),l}function mh(r){let l=w.useContext(Df);return l||Ce(!1),l}function hh(r){let l=w.useContext(Ht);return l||Ce(!1),l}function Ff(r){let l=hh(),s=l.matches[l.matches.length-1];return s.route.id||Ce(!1),s.route.id}function vh(){var r;let l=w.useContext(zf),s=mh(),u=Ff();return l!==void 0?l:(r=s.errors)==null?void 0:r[u]}function yh(){let{router:r}=ph(Bf.UseNavigateStable),l=Ff(Uf.UseNavigateStable),s=w.useRef(!1);return $f(()=>{s.current=!0}),w.useCallback(function(c,d){d===void 0&&(d={}),s.current&&(typeof c=="number"?r.navigate(c):r.navigate(c,ni({fromRouteId:l},d)))},[r,l])}const af={};function gh(r,l,s){af[r]||(af[r]=!0)}function wh(r,l){r?.v7_startTransition,r?.v7_relativeSplatPath}function Sh(r){let{to:l,replace:s,state:u,relative:c}=r;pr()||Ce(!1);let{future:d,static:p}=w.useContext(Wt),{matches:m}=w.useContext(Ht),{pathname:y}=Qt(),x=Gs(),k=Ys(l,Qs(m,d.v7_relativeSplatPath),y,c==="path"),R=JSON.stringify(k);return w.useEffect(()=>x(JSON.parse(R),{replace:s,state:u,relative:c}),[x,R,c,s,u]),null}function Ot(r){Ce(!1)}function Eh(r){let{basename:l="/",children:s=null,location:u,navigationType:c=hn.Pop,navigator:d,static:p=!1,future:m}=r;pr()&&Ce(!1);let y=l.replace(/^\/*/,"/"),x=w.useMemo(()=>({basename:y,navigator:d,static:p,future:ni({v7_relativeSplatPath:!1},m)}),[y,m,d,p]);typeof u=="string"&&(u=dr(u));let{pathname:k="/",search:R="",hash:T="",state:$=null,key:z="default"}=u,M=w.useMemo(()=>{let P=cr(k,y);return P==null?null:{location:{pathname:P,search:R,hash:T,state:$,key:z},navigationType:c}},[y,k,R,T,$,z,c]);return M==null?null:w.createElement(Wt.Provider,{value:x},w.createElement(xl.Provider,{children:s,value:M}))}function xh(r){let{children:l,location:s}=r;return oh(Ms(l),s)}new Promise(()=>{});function Ms(r,l){l===void 0&&(l=[]);let s=[];return w.Children.forEach(r,(u,c)=>{if(!w.isValidElement(u))return;let d=[...l,c];if(u.type===w.Fragment){s.push.apply(s,Ms(u.props.children,d));return}u.type!==Ot&&Ce(!1),!u.props.index||!u.props.children||Ce(!1);let p={id:u.props.id||d.join("-"),caseSensitive:u.props.caseSensitive,element:u.props.element,Component:u.props.Component,index:u.props.index,path:u.props.path,loader:u.props.loader,action:u.props.action,errorElement:u.props.errorElement,ErrorBoundary:u.props.ErrorBoundary,hasErrorBoundary:u.props.ErrorBoundary!=null||u.props.errorElement!=null,shouldRevalidate:u.props.shouldRevalidate,handle:u.props.handle,lazy:u.props.lazy};u.props.children&&(p.children=Ms(u.props.children,d)),s.push(p)}),s}function Sl(){return Sl=Object.assign?Object.assign.bind():function(r){for(var l=1;l{let u=r[s];return l.concat(Array.isArray(u)?u.map(c=>[s,c]):[[s,u]])},[]))}function _h(r,l){let s=Ds(r);return l&&l.forEach((u,c)=>{s.has(c)||l.getAll(c).forEach(d=>{s.append(c,d)})}),s}const Rh=["onClick","relative","reloadDocument","replace","state","target","to","preventScrollReset","viewTransition"],Nh=["aria-current","caseSensitive","className","end","style","to","viewTransition","children"],Th="6";try{window.__reactRouterVersion=Th}catch{}const Ph=w.createContext({isTransitioning:!1}),Ah="startTransition",uf=_m[Ah];function Lh(r){let{basename:l,children:s,future:u,window:c}=r,d=w.useRef();d.current==null&&(d.current=Im({window:c,v5Compat:!0}));let p=d.current,[m,y]=w.useState({action:p.action,location:p.location}),{v7_startTransition:x}=u||{},k=w.useCallback(R=>{x&&uf?uf(()=>y(R)):y(R)},[y,x]);return w.useLayoutEffect(()=>p.listen(k),[p,k]),w.useEffect(()=>wh(u),[u]),w.createElement(Eh,{basename:l,children:s,location:m.location,navigationType:m.action,navigator:p,future:u})}const Ih=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u",Oh=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,jh=w.forwardRef(function(l,s){let{onClick:u,relative:c,reloadDocument:d,replace:p,state:m,target:y,to:x,preventScrollReset:k,viewTransition:R}=l,T=Vf(l,Rh),{basename:$}=w.useContext(Wt),z,M=!1;if(typeof x=="string"&&Oh.test(x)&&(z=x,Ih))try{let Y=new URL(window.location.href),q=x.startsWith("//")?new URL(Y.protocol+x):new URL(x),b=cr(q.pathname,$);q.origin===Y.origin&&b!=null?x=b+q.search+q.hash:M=!0}catch{}let P=ih(x,{relative:c}),D=zh(x,{replace:p,state:m,target:y,preventScrollReset:k,relative:c,viewTransition:R});function G(Y){u&&u(Y),Y.defaultPrevented||D(Y)}return w.createElement("a",Sl({},T,{href:z||P,onClick:M||d?u:G,ref:s,target:y}))}),Mh=w.forwardRef(function(l,s){let{"aria-current":u="page",caseSensitive:c=!1,className:d="",end:p=!1,style:m,to:y,viewTransition:x,children:k}=l,R=Vf(l,Nh),T=kl(y,{relative:R.relative}),$=Qt(),z=w.useContext(Df),{navigator:M,basename:P}=w.useContext(Wt),D=z!=null&&$h(T)&&x===!0,G=M.encodeLocation?M.encodeLocation(T).pathname:T.pathname,Y=$.pathname,q=z&&z.navigation&&z.navigation.location?z.navigation.location.pathname:null;c||(Y=Y.toLowerCase(),q=q?q.toLowerCase():null,G=G.toLowerCase()),q&&P&&(q=cr(q,P)||q);const b=G!=="/"&&G.endsWith("/")?G.length-1:G.length;let ee=Y===G||!p&&Y.startsWith(G)&&Y.charAt(b)==="/",te=q!=null&&(q===G||!p&&q.startsWith(G)&&q.charAt(G.length)==="/"),ne={isActive:ee,isPending:te,isTransitioning:D},ye=ee?u:void 0,oe;typeof d=="function"?oe=d(ne):oe=[d,ee?"active":null,te?"pending":null,D?"transitioning":null].filter(Boolean).join(" ");let _e=typeof m=="function"?m(ne):m;return w.createElement(jh,Sl({},R,{"aria-current":ye,className:oe,ref:s,style:_e,to:y,viewTransition:x}),typeof k=="function"?k(ne):k)});var zs;(function(r){r.UseScrollRestoration="useScrollRestoration",r.UseSubmit="useSubmit",r.UseSubmitFetcher="useSubmitFetcher",r.UseFetcher="useFetcher",r.useViewTransitionState="useViewTransitionState"})(zs||(zs={}));var cf;(function(r){r.UseFetcher="useFetcher",r.UseFetchers="useFetchers",r.UseScrollRestoration="useScrollRestoration"})(cf||(cf={}));function Dh(r){let l=w.useContext(El);return l||Ce(!1),l}function zh(r,l){let{target:s,replace:u,state:c,preventScrollReset:d,relative:p,viewTransition:m}=l===void 0?{}:l,y=Gs(),x=Qt(),k=kl(r,{relative:p});return w.useCallback(R=>{if(Ch(R,s)){R.preventDefault();let T=u!==void 0?u:wl(x)===wl(k);y(r,{replace:T,state:c,preventScrollReset:d,relative:p,viewTransition:m})}},[x,y,k,u,c,s,r,d,p,m])}function Sw(r){let l=w.useRef(Ds(r)),s=w.useRef(!1),u=Qt(),c=w.useMemo(()=>_h(u.search,s.current?null:l.current),[u.search]),d=Gs(),p=w.useCallback((m,y)=>{const x=Ds(typeof m=="function"?m(c):m);s.current=!0,d("?"+x,y)},[d,c]);return[c,p]}function $h(r,l){l===void 0&&(l={});let s=w.useContext(Ph);s==null&&Ce(!1);let{basename:u}=Dh(zs.useViewTransitionState),c=kl(r,{relative:l.relative});if(!s.isTransitioning)return!1;let d=cr(s.currentLocation.pathname,u)||s.currentLocation.pathname,p=cr(s.nextLocation.pathname,u)||s.nextLocation.pathname;return js(c.pathname,p)!=null||js(c.pathname,d)!=null}const Bh=new Set(["failed","errored","stuck","crashed"]),Uh=new Set(["rate-limited","rate_limited","waiting"]),Fh={"awaiting-input":"respond",errored:"reset","rate-limited":"nudge",stalled:"nudge"};function Vh(r,l){const s=new Map;for(const c of l)s.set(c.agentName,c.prompt);const u=[];for(const c of r){const d=s.has(c.name),p=Wh(c,d);p!==null&&u.push({name:c.name,reason:p,detail:Qh(c,p,s.get(c.name)),action:Fh[p]})}return u}function Wh(r,l){if(l)return"awaiting-input";const s=r.state.toLowerCase();return Bh.has(s)?"errored":Uh.has(s)?"rate-limited":Hh(r,s)?"stalled":null}function Hh(r,l){return l==="detached"?!0:r.running&&r.session===void 0}function Qh(r,l,s){switch(l){case"awaiting-input":return Yh(s);case"errored":return`Exited ${r.state}.`;case"rate-limited":return"Throttled by a provider limit.";case"stalled":return r.state.toLowerCase()==="detached"?"Detached from its session.":"Running with no live session."}}function Yh(r){if(r===void 0)return"Awaiting your decision.";const l=r.split(` +`,1)[0]?.trim()??"";return l.length>0?l:"Awaiting your decision."}function Gh(r){return r.filter(l=>l.phase==="blocked").map(l=>({id:l.id,title:l.title,reason:qh(l),remedy:Kh(l),scope:l.scope}))}function qh(r){const l=Xh(r);if(l!==null)return`Blocked at ${l}`;const s=r.statusCounts.blocked??0;return s>0?`${s} blocked step${s===1?"":"s"}`:"Blocked, awaiting operator"}function Kh(r){return r.activeAssignees.length===0?"No worker assigned. Claim or dispatch one.":"Open run detail to review the blocked step."}function Xh(r){if(r.progress.status==="active_step"||r.progress.status==="stage_only"){const l=r.progress.stage;if(l.status==="available")return l.label}return null}const Wf=/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i,Jh={bead:"bead.",session:"session."};function sr(r){return r instanceof Error?r.message:typeof r=="string"?r:"unknown error"}function Zh(r){if(!r)return"";let l=r.length;for(;l>0&&r.charCodeAt(l-1)===47;)l--;const s=r.slice(0,l);return s.slice(s.lastIndexOf("/")+1)||s}const bh="polecat";function ev(r){return Zh(r).toLowerCase().includes(bh)}function tv(r){return r.filter(l=>!l.read&&!ev(l.from))}const nv="modulepreload",rv=function(r){return"/"+r},ff={},Yt=function(l,s,u){let c=Promise.resolve();if(s&&s.length>0){let y=function(x){return Promise.all(x.map(k=>Promise.resolve(k).then(R=>({status:"fulfilled",value:R}),R=>({status:"rejected",reason:R}))))};document.getElementsByTagName("link");const p=document.querySelector("meta[property=csp-nonce]"),m=p?.nonce||p?.getAttribute("nonce");c=y(s.map(x=>{if(x=rv(x),x in ff)return;ff[x]=!0;const k=x.endsWith(".css"),R=k?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${x}"]${R}`))return;const T=document.createElement("link");if(T.rel=k?"stylesheet":nv,k||(T.as="script"),T.crossOrigin="",T.href=x,m&&T.setAttribute("nonce",m),document.head.appendChild(T),k)return new Promise(($,z)=>{T.addEventListener("load",$),T.addEventListener("error",()=>z(new Error(`Unable to preload CSS for ${x}`)))})}))}function d(p){const m=new Event("vite:preloadError",{cancelable:!0});if(m.payload=p,window.dispatchEvent(m),!m.defaultPrevented)throw p}return c.then(p=>{for(const m of p||[])m.status==="rejected"&&d(m.reason);return l().catch(d)})};let ri=null;function iv(r){if(!Wf.test(r))throw new Error(`invalid city name: ${r}`);ri=r}function Cl(){return ri}function Gt(r){const l=ri;if(l===null)throw new Error(`${r} called before an active city was resolved`);return l}function mn(r){if(ri===null)throw new Error(`cityPath("${r}") called before an active city was resolved`);return`/api/city/${encodeURIComponent(ri)}${r}`}async function lv(r,l,s,u){const c={Accept:"application/json"};u!==void 0&&(c["Content-Type"]="application/json"),r!=="GET"&&(c["X-GC-Request"]="dashboard");const d={method:r,headers:c,credentials:"same-origin"};u!==void 0&&(d.body=JSON.stringify(u));const p=await fetch(l,d);if(!p.ok){const y=await p.text(),x=ov(y),k=x?.error??(y.trim()||p.statusText||`HTTP ${p.status}`);throw new Hf(p.status,k,x?.kind,x?.reason)}let m;try{m=await p.json()}catch(y){throw new Qf(l,`body must be valid JSON: ${av(y)}`)}return s(m,l)}function ov(r){if(r.trim().length!==0)try{const l=JSON.parse(r);return sv(l)?l:void 0}catch{return}}function sv(r){if(typeof r!="object"||r===null)return!1;const l=r;return typeof l.error!="string"||l.kind!==void 0&&typeof l.kind!="string"?!1:l.reason===void 0||typeof l.reason=="string"}async function yt(r,l,s,u){return lv(r,l,s,u)}class Hf extends Error{constructor(l,s,u,c){super(s),this.status=l,this.kind=u,this.reason=c,this.name="ApiClientError"}status;kind;reason}class Qf extends Error{constructor(l,s){super(`Invalid API response for ${l}: ${s}`),this.url=l,this.detail=s,this.name="ApiResponseDecodeError"}url;detail}function av(r){return r instanceof Error?r.message:typeof r=="string"?r:"unknown error"}function Mn(r,l){throw new Qf(r,l)}function uv(r){return typeof r=="object"&&r!==null&&!Array.isArray(r)}function _l(r,l,s){return uv(r)||Mn(l,`${s} must be an object`),r}function ut(r,l,s,u){typeof r[u]!="string"&&Mn(l,`${s}.${u} must be a string`)}function Yf(r,l,s,u){const c=r[u];c!==null&&typeof c!="string"&&Mn(l,`${s}.${u} must be a string or null`)}function Sn(r,l,s,u){typeof r[u]!="boolean"&&Mn(l,`${s}.${u} must be a boolean`)}function df(r,l,s,u){typeof r[u]!="number"&&Mn(l,`${s}.${u} must be a number`)}function ct(r,l,s,u){Array.isArray(r[u])||Mn(l,`${s}.${u} must be an array`)}function tt(r,l,s,u){_l(r[u],l,`${s}.${u}`)}function cv(r,l,s,u){const c=r[u];c!==null&&(!Array.isArray(c)||c.some(d=>typeof d!="string"))&&Mn(l,`${s}.${u} must be an array of strings or null`)}function Nt(r,l){return(s,u)=>{const c=_l(s,u,r);return l?.(c,u),c}}function Gf(r,l){return Nt(r,(s,u)=>{ct(s,u,r,"items"),l?.(s,u)})}const fv=Nt("health",(r,l)=>{Sn(r,l,"health","ok"),ut(r,l,"health","ts")}),dv=Gf("commits",(r,l)=>{ut(r,l,"commits","view")}),pv=Gf("builds",(r,l)=>{Yf(r,l,"builds","source"),Sn(r,l,"builds","failed_marker")}),mv=Nt("config",(r,l)=>{ut(r,l,"config","cityName"),ut(r,l,"config","cityRoot"),Sn(r,l,"config","useFixtures"),Sn(r,l,"config","readOnly"),ut(r,l,"config","operatorAlias"),ut(r,l,"config","operatorWireAlias"),ut(r,l,"config","decisionLabel"),cv(r,l,"config","enabledModules"),Yf(r,l,"config","defaultView")}),hv=Nt("system health",(r,l)=>{tt(r,l,"system health","admin"),tt(r,l,"system health","host")});function Ps(r,l,s,u){tt(r,l,s,u);const c=r[u],d=`${s}.${u}`;ut(c,l,d,"status")}const vv=Nt("local tool versions",(r,l)=>{Ps(r,l,"local tool versions","dolt"),Ps(r,l,"local tool versions","beads"),Ps(r,l,"local tool versions","gc")}),yv=Nt("dolt trend",(r,l)=>{Sn(r,l,"dolt trend","available"),ct(r,l,"dolt trend","samples")}),gv=Nt("rig store health",(r,l)=>{Sn(r,l,"rig store health","available"),ct(r,l,"rig store health","rigs")});function pf(r,l){const s=_l(r,l,"supervisor status.status");tt(s,l,"supervisor status.status","work")}const wv=Nt("supervisor status",(r,l)=>{Sn(r,l,"supervisor status","available"),r.available===!0?(ut(r,l,"supervisor status","sampledAt"),pf(r.status,l)):(ut(r,l,"supervisor status","reason"),r.status!==null&&pf(r.status,l))}),Sv=Nt("run diff",(r,l)=>{ut(r,l,"run diff","kind"),tt(r,l,"run diff","rootPath"),tt(r,l,"run diff","comparison"),ct(r,l,"run diff","status"),ct(r,l,"run diff","changedFiles"),ut(r,l,"run diff","patch"),Sn(r,l,"run diff","truncated")}),Ev=Nt("run summary",(r,l)=>{df(r,l,"run summary","totalActive"),df(r,l,"run summary","totalHistorical"),ct(r,l,"run summary","lanes"),ct(r,l,"run summary","historicalLanes"),ct(r,l,"run summary","blockedLanes"),ct(r,l,"run summary","recentChanges"),tt(r,l,"run summary","runCounts"),tt(r,l,"run summary","census")}),xv=Nt("formula run detail",(r,l)=>{ut(r,l,"formula run detail","runId"),tt(r,l,"formula run detail","formula"),tt(r,l,"formula run detail","formulaDetail"),tt(r,l,"formula run detail","executionPath"),tt(r,l,"formula run detail","snapshotEventSeq"),tt(r,l,"formula run detail","completeness");const s=_l(r.progress,l,"formula run detail.progress");tt(s,l,"formula run detail.progress","statusCounts"),ct(r,l,"formula run detail","stages"),ct(r,l,"formula run detail","nodes"),ct(r,l,"formula run detail","edges"),ct(r,l,"formula run detail","lanes")});function kv(r,l="request failed"){if(r instanceof Hf){const s={message:r.message,status:r.status};return r.kind!==void 0&&(s.kind=r.kind),s}return r instanceof Error?{message:r.message}:{message:l}}function Rt(r,l="request failed"){const s=kv(r,l);return s.status===void 0?s.message:`${s.status} ${s.message}`}const fr={health(){return yt("GET","/api/health",fv)},listCommits(r){return yt("GET",`/api/git/commits?view=${encodeURIComponent(r)}`,dv)},listBuilds(){return yt("GET","/api/builds",pv)},config(){return yt("GET",mn("/config"),mv)},systemHealth(){return yt("GET","/api/health/system",hv)},localToolVersions(){return yt("GET","/api/health/local-tools",vv)},doltTrend(){return yt("GET",mn("/dolt-noms/trend"),yv)},rigStoreHealth(){return yt("GET",mn("/rig-store-health"),gv)},supervisorStatus(){return yt("GET",mn("/supervisor-status"),wv)},runDiff(r,l,s){const u=Cv(s);return yt("POST",mn(`/runs/${encodeURIComponent(r)}/diff${u}`),Sv,l)},runSummary(){return yt("GET",mn("/runs/summary"),Ev)},runDetail(r){return yt("GET",mn(`/runs/${encodeURIComponent(r)}/detail`),xv)},runDetailStreamUrl(r){return mn(`/runs/${encodeURIComponent(r)}/detail/stream`)}};function Cv(r){const l=new URLSearchParams;r?.scopeKind&&r.scopeRef&&(l.set("scope_kind",r.scopeKind),l.set("scope_ref",r.scopeRef));const s=l.toString();return s.length>0?`?${s}`:""}const ii=["agents","beads","runs","mail","activity","health"],_v=5,Rv=new Map(ii.map((r,l)=>[r,l]));function $s(r,l={}){const s=Nv(),u=[];let c=0;for(const x of r)for(const k of x.getItems()){u.push({item:k,index:c});const R=s[k.domain],T=[...R.items,k];s[k.domain]={domain:k.domain,attention:R.attention+(k.severity==="attention"?1:0),watch:R.watch+(k.severity==="watch"?1:0),unavailable:R.unavailable+(k.severity==="unavailable"?1:0),severity:k.severity==="unavailable"?R.severity:Tv(R.severity,k.severity),items:T},c+=1}const d=u.sort((x,k)=>Pv(x.item,k.item)||x.index-k.index).map(({item:x})=>x),p=l.topLimit??_v,m=d.slice(0,p),y=Av(d.slice(p));return{items:d,topItems:m,overflowByDomain:y,byDomain:s}}function Nv(){const r={};for(const l of ii)r[l]={domain:l,attention:0,watch:0,unavailable:0,severity:null,items:[]};return r}function Tv(r,l){return r==="attention"||l==="attention"?"attention":"watch"}function Pv(r,l){return mf(r.severity)-mf(l.severity)||vl(l.current??!0)-vl(r.current??!0)||vl(l.actionable??!1)-vl(r.actionable??!1)||hf(l.updatedAt)-hf(r.updatedAt)||vf(r.domain)-vf(l.domain)}function mf(r){switch(r){case"attention":return 0;case"watch":return 1;case"unavailable":return 2}}function vl(r){return r?1:0}function hf(r){if(r===void 0)return 0;const l=Date.parse(r);return Number.isFinite(l)?l:0}function vf(r){return Rv.get(r)??ii.length}function Av(r){const l=[];for(const s of ii){let u=0,c=0,d=0;for(const m of r)m.domain===s&&(m.severity==="attention"?u+=1:m.severity==="watch"?c+=1:d+=1);const p=u+c+d;p>0&&l.push({domain:s,attention:u,watch:c,unavailable:d,total:p})}return l}const Lv=$s([]),qf=w.createContext(Lv);function Iv({contributors:r,topLimit:l,children:s}){const u=w.useMemo(()=>l===void 0?$s(r):$s(r,{topLimit:l}),[r,l]);return N.jsx(qf.Provider,{value:u,children:s})}function Ov(){return w.useContext(qf)}const qs=new Map;function As(r){return qs.get(r)?.value}function yl(r){return qs.get(r)?.fetchedAt}function jv(r,l){qs.set(r,{value:l,fetchedAt:new Date().toISOString()})}function Vt(r,l,s){const u=w.useRef(l);u.current=l;const c=w.useRef(s?.refreshFetcher);c.current=s?.refreshFetcher;const d=w.useRef(s?.sseRefreshFetcher);d.current=s?.sseRefreshFetcher;const p=w.useRef(s?.onError);p.current=s?.onError;const m=w.useRef(r);m.current=r;const y=w.useRef(0),[x,k]=w.useState(()=>As(r)),[R,T]=w.useState(()=>As(r)===void 0),[$,z]=w.useState(null),[M,P]=w.useState(()=>yl(r)),D=w.useCallback(async q=>{const b=y.current+1;y.current=b;const ee=r;T(!0),z(null);try{const te=await q(),ne=y.current===b,ye=m.current===ee;ne&&ye?(jv(ee,te),k(te),P(yl(ee))):ye&&(k(oe=>oe===void 0?te:oe),P(oe=>oe??yl(ee)??new Date().toISOString()))}catch(te){y.current===b&&(z(te instanceof Error?te.message:"failed to load"),p.current?.(te))}finally{y.current===b&&T(!1)}},[r]),G=w.useCallback(()=>D(c.current??u.current),[D]),Y=w.useCallback(()=>D(d.current??c.current??u.current),[D]);return w.useEffect(()=>{const q=As(r);return k(q),T(q===void 0),P(yl(r)),D(u.current),()=>{y.current+=1}},[r,D]),{data:x,loading:R,error:$,fetchedAt:M,refresh:G,cheapRefresh:Y}}var Mv=async(r,l)=>{let s=typeof l=="function"?await l(r):l;if(s)return r.scheme==="bearer"?`Bearer ${s}`:r.scheme==="basic"?`Basic ${btoa(s)}`:s},Dv={bodySerializer:r=>JSON.stringify(r,(l,s)=>typeof s=="bigint"?s.toString():s)},zv=r=>{switch(r){case"label":return".";case"matrix":return";";case"simple":return",";default:return"&"}},$v=r=>{switch(r){case"form":return",";case"pipeDelimited":return"|";case"spaceDelimited":return"%20";default:return","}},Bv=r=>{switch(r){case"label":return".";case"matrix":return";";case"simple":return",";default:return"&"}},Kf=({allowReserved:r,explode:l,name:s,style:u,value:c})=>{if(!l){let m=(r?c:c.map(y=>encodeURIComponent(y))).join($v(u));switch(u){case"label":return`.${m}`;case"matrix":return`;${s}=${m}`;case"simple":return m;default:return`${s}=${m}`}}let d=zv(u),p=c.map(m=>u==="label"||u==="simple"?r?m:encodeURIComponent(m):Rl({allowReserved:r,name:s,value:m})).join(d);return u==="label"||u==="matrix"?d+p:p},Rl=({allowReserved:r,name:l,value:s})=>{if(s==null)return"";if(typeof s=="object")throw new Error("Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.");return`${l}=${r?s:encodeURIComponent(s)}`},Xf=({allowReserved:r,explode:l,name:s,style:u,value:c,valueOnly:d})=>{if(c instanceof Date)return d?c.toISOString():`${s}=${c.toISOString()}`;if(u!=="deepObject"&&!l){let y=[];Object.entries(c).forEach(([k,R])=>{y=[...y,k,r?R:encodeURIComponent(R)]});let x=y.join(",");switch(u){case"form":return`${s}=${x}`;case"label":return`.${x}`;case"matrix":return`;${s}=${x}`;default:return x}}let p=Bv(u),m=Object.entries(c).map(([y,x])=>Rl({allowReserved:r,name:u==="deepObject"?`${s}[${y}]`:y,value:x})).join(p);return u==="label"||u==="matrix"?p+m:m},Uv=/\{[^{}]+\}/g,Fv=({path:r,url:l})=>{let s=l,u=l.match(Uv);if(u)for(let c of u){let d=!1,p=c.substring(1,c.length-1),m="simple";p.endsWith("*")&&(d=!0,p=p.substring(0,p.length-1)),p.startsWith(".")?(p=p.substring(1),m="label"):p.startsWith(";")&&(p=p.substring(1),m="matrix");let y=r[p];if(y==null)continue;if(Array.isArray(y)){s=s.replace(c,Kf({explode:d,name:p,style:m,value:y}));continue}if(typeof y=="object"){s=s.replace(c,Xf({explode:d,name:p,style:m,value:y,valueOnly:!0}));continue}if(m==="matrix"){s=s.replace(c,`;${Rl({name:p,value:y})}`);continue}let x=encodeURIComponent(m==="label"?`.${y}`:y);s=s.replace(c,x)}return s},Jf=({allowReserved:r,array:l,object:s}={})=>u=>{let c=[];if(u&&typeof u=="object")for(let d in u){let p=u[d];if(p!=null)if(Array.isArray(p)){let m=Kf({allowReserved:r,explode:!0,name:d,style:"form",value:p,...l});m&&c.push(m)}else if(typeof p=="object"){let m=Xf({allowReserved:r,explode:!0,name:d,style:"deepObject",value:p,...s});m&&c.push(m)}else{let m=Rl({allowReserved:r,name:d,value:p});m&&c.push(m)}}return c.join("&")},Vv=r=>{if(!r)return"stream";let l=r.split(";")[0]?.trim();if(l){if(l.startsWith("application/json")||l.endsWith("+json"))return"json";if(l==="multipart/form-data")return"formData";if(["application/","audio/","image/","video/"].some(s=>l.startsWith(s)))return"blob";if(l.startsWith("text/"))return"text"}},Wv=async({security:r,...l})=>{for(let s of r){let u=await Mv(s,l.auth);if(!u)continue;let c=s.name??"Authorization";switch(s.in){case"query":l.query||(l.query={}),l.query[c]=u;break;case"cookie":l.headers.append("Cookie",`${c}=${u}`);break;default:l.headers.set(c,u);break}return}},yf=r=>Hv({baseUrl:r.baseUrl,path:r.path,query:r.query,querySerializer:typeof r.querySerializer=="function"?r.querySerializer:Jf(r.querySerializer),url:r.url}),Hv=({baseUrl:r,path:l,query:s,querySerializer:u,url:c})=>{let d=c.startsWith("/")?c:`/${c}`,p=(r??"")+d;l&&(p=Fv({path:l,url:p}));let m=s?u(s):"";return m.startsWith("?")&&(m=m.substring(1)),m&&(p+=`?${m}`),p},gf=(r,l)=>{let s={...r,...l};return s.baseUrl?.endsWith("/")&&(s.baseUrl=s.baseUrl.substring(0,s.baseUrl.length-1)),s.headers=Zf(r.headers,l.headers),s},Zf=(...r)=>{let l=new Headers;for(let s of r){if(!s||typeof s!="object")continue;let u=s instanceof Headers?s.entries():Object.entries(s);for(let[c,d]of u)if(d===null)l.delete(c);else if(Array.isArray(d))for(let p of d)l.append(c,p);else d!==void 0&&l.set(c,typeof d=="object"?JSON.stringify(d):d)}return l},Ls=class{_fns;constructor(){this._fns=[]}clear(){this._fns=[]}getInterceptorIndex(r){return typeof r=="number"?this._fns[r]?r:-1:this._fns.indexOf(r)}exists(r){let l=this.getInterceptorIndex(r);return!!this._fns[l]}eject(r){let l=this.getInterceptorIndex(r);this._fns[l]&&(this._fns[l]=null)}update(r,l){let s=this.getInterceptorIndex(r);return this._fns[s]?(this._fns[s]=l,r):!1}use(r){return this._fns=[...this._fns,r],this._fns.length-1}},Qv=()=>({error:new Ls,request:new Ls,response:new Ls}),Yv=Jf({allowReserved:!1,array:{explode:!0,style:"form"},object:{explode:!0,style:"deepObject"}}),Gv={"Content-Type":"application/json"},bf=(r={})=>({...Dv,headers:Gv,parseAs:"auto",querySerializer:Yv,...r}),ed=(r={})=>{let l=gf(bf(),r),s=()=>({...l}),u=p=>(l=gf(l,p),s()),c=Qv(),d=async p=>{let m={...l,...p,fetch:p.fetch??l.fetch??globalThis.fetch,headers:Zf(l.headers,p.headers)};m.security&&await Wv({...m,security:m.security}),m.body&&m.bodySerializer&&(m.body=m.bodySerializer(m.body)),(m.body===void 0||m.body==="")&&m.headers.delete("Content-Type");let y=yf(m),x={redirect:"follow",...m},k=new Request(y,x);for(let P of c.request._fns)P&&(k=await P(k,m));let R=m.fetch,T=await R(k);for(let P of c.response._fns)P&&(T=await P(T,k,m));let $={request:k,response:T};if(T.ok){if(T.status===204||T.headers.get("Content-Length")==="0")return m.responseStyle==="data"?{}:{data:{},...$};let P=(m.parseAs==="auto"?Vv(T.headers.get("Content-Type")):m.parseAs)??"json";if(P==="stream")return m.responseStyle==="data"?T.body:{data:T.body,...$};let D=await T[P]();return P==="json"&&(m.responseValidator&&await m.responseValidator(D),m.responseTransformer&&(D=await m.responseTransformer(D))),m.responseStyle==="data"?D:{data:D,...$}}let z=await T.text();try{z=JSON.parse(z)}catch{}let M=z;for(let P of c.error._fns)P&&(M=await P(z,T,k,m));if(M=M||{},m.throwOnError)throw M;return m.responseStyle==="data"?void 0:{error:M,...$}};return{buildUrl:yf,connect:p=>d({...p,method:"CONNECT"}),delete:p=>d({...p,method:"DELETE"}),get:p=>d({...p,method:"GET"}),getConfig:s,head:p=>d({...p,method:"HEAD"}),interceptors:c,options:p=>d({...p,method:"OPTIONS"}),patch:p=>d({...p,method:"PATCH"}),post:p=>d({...p,method:"POST"}),put:p=>d({...p,method:"PUT"}),request:d,setConfig:u,trace:p=>d({...p,method:"TRACE"})}};const me=ed(bf()),qv=r=>(r?.client??me).get({url:"/health",...r}),Kv=r=>(r?.client??me).get({url:"/v0/cities",...r}),Xv=r=>(r.client??me).get({url:"/v0/city/{cityName}/agents",...r}),Jv=r=>(r.client??me).get({url:"/v0/city/{cityName}/bead/{id}",...r}),Zv=r=>(r.client??me).patch({url:"/v0/city/{cityName}/bead/{id}",...r,headers:{"Content-Type":"application/json",...r.headers}}),bv=r=>(r.client??me).post({url:"/v0/city/{cityName}/bead/{id}/close",...r}),ey=r=>(r.client??me).get({url:"/v0/city/{cityName}/beads",...r}),ty=r=>(r.client??me).post({url:"/v0/city/{cityName}/beads",...r,headers:{"Content-Type":"application/json",...r.headers}}),ny=r=>(r.client??me).get({url:"/v0/city/{cityName}/events",...r}),ry=r=>(r.client??me).get({url:"/v0/city/{cityName}/formulas/feed",...r}),iy=r=>(r.client??me).get({url:"/v0/city/{cityName}/formulas/{name}",...r}),ly=r=>(r.client??me).get({url:"/v0/city/{cityName}/health",...r}),oy=r=>(r.client??me).get({url:"/v0/city/{cityName}/mail",...r}),sy=r=>(r.client??me).post({url:"/v0/city/{cityName}/mail",...r,headers:{"Content-Type":"application/json",...r.headers}}),ay=r=>(r.client??me).get({url:"/v0/city/{cityName}/mail/thread/{id}",...r}),uy=r=>(r.client??me).post({url:"/v0/city/{cityName}/mail/{id}/archive",...r}),cy=r=>(r.client??me).post({url:"/v0/city/{cityName}/mail/{id}/mark-unread",...r}),fy=r=>(r.client??me).post({url:"/v0/city/{cityName}/mail/{id}/read",...r}),dy=r=>(r.client??me).post({url:"/v0/city/{cityName}/mail/{id}/reply",...r,headers:{"Content-Type":"application/json",...r.headers}}),py=r=>(r.client??me).get({url:"/v0/city/{cityName}/rigs",...r}),my=r=>(r.client??me).get({url:"/v0/city/{cityName}/session/{id}/pending",...r}),hy=r=>(r.client??me).post({url:"/v0/city/{cityName}/session/{id}/respond",...r,headers:{"Content-Type":"application/json",...r.headers}}),vy=r=>(r.client??me).get({url:"/v0/city/{cityName}/session/{id}/transcript",...r}),yy=r=>(r.client??me).get({url:"/v0/city/{cityName}/sessions",...r}),gy=r=>(r.client??me).post({url:"/v0/city/{cityName}/sling",...r,headers:{"Content-Type":"application/json",...r.headers}}),wy=r=>(r.client??me).get({url:"/v0/city/{cityName}/status",...r}),Sy=r=>(r.client??me).get({url:"/v0/city/{cityName}/workflow/{workflow_id}",...r});class gn extends Error{constructor(l,s,u){super(s),this.status=l,this.requestId=u}status;requestId;name="SupervisorApiError"}async function pe(r,l){let s;try{s=await r}catch(d){throw Ey(d)}const{response:u}=s;if(u===void 0)throw new gn(void 0,Bs(s.error),void 0);if(!u.ok||s.error!==void 0)throw new gn(u.status,Bs(s.error,u.statusText),u.headers.get("x-gc-request-id")??void 0);const c=s.data;if(c===void 0)throw new gn(u.status,l,u.headers.get("x-gc-request-id")??void 0);return c}function Ey(r){return r instanceof gn?r:new gn(void 0,Bs(r),void 0)}function Bs(r,l="gc supervisor request failed"){if(typeof r=="string"&&r.trim().length>0)return r.trim();if(r instanceof Error&&r.message.trim().length>0)return r.message.trim();if(xy(r))for(const s of["error","message","detail"]){const u=r[s];if(typeof u=="string"&&u.trim().length>0)return u.trim()}return l}function xy(r){return typeof r=="object"&&r!==null&&!Array.isArray(r)}const ky="";function Cy(){const r=globalThis.location?.origin;return typeof r=="string"&&r.length>0&&r!=="null"?r:ky}function _y(r){if(!r.startsWith("/"))return r;const l=globalThis.location?.origin;return typeof l!="string"||l.length===0||l==="null"?r:new URL(r,l).toString().replace(/\/$/,"")}function wf(r,l,s){const u=r.replace(/\/$/,""),c=new URLSearchParams(s).toString(),d=c.length>0?`${l}?${c}`:l;return u===""?d:u.startsWith("/")?`${u}${d}`:new URL(d,`${u}/`).toString()}const Ry=6e4,_t={"X-GC-Request":"dashboard"};let Sf=null;const Ef=new Map;function td(r={}){const l=r.baseUrl??Cy(),u={baseUrl:_y(l),headers:{Accept:"application/json"},responseStyle:"fields",throwOnError:!1},c=r.client??ed({...u,fetch:Ty(r.fetch??globalThis.fetch,nd(r.timeoutMs))});return{baseUrl:l,health(){return pe(qv({client:c}),"gc supervisor health response was empty")},cityHealth(d){return pe(ly({client:c,path:{cityName:d}}),"gc supervisor city health response was empty")},cityStatus(d){return pe(wy({client:c,path:{cityName:d}}),"gc supervisor status response was empty")},listCities(){return pe(Kv({client:c}),"gc supervisor cities response was empty")},listAgents(d){return pe(Xv({client:c,path:{cityName:d}}),"gc supervisor agents response was empty")},listRigs(d){return pe(py({client:c,path:{cityName:d}}),"gc supervisor rigs response was empty")},listBeads(d,p){return pe(ey({client:c,path:{cityName:d},...p===void 0?{}:{query:p}}),"gc supervisor beads response was empty")},listEvents(d,p){return pe(ny({client:c,path:{cityName:d},...p===void 0?{}:{query:p}}),"gc supervisor events response was empty")},getBead(d,p){return pe(Jv({client:c,path:{cityName:d,id:p}}),"gc supervisor bead response was empty")},createBead(d,p){return pe(ty({client:c,path:{cityName:d},headers:_t,body:p}),"gc supervisor bead create response was empty")},updateBead(d,p,m){return pe(Zv({client:c,path:{cityName:d,id:p},headers:_t,body:m}),"gc supervisor bead update response was empty")},closeBead(d,p){return pe(bv({client:c,path:{cityName:d,id:p},headers:_t}),"gc supervisor bead close response was empty")},sling(d,p){return pe(gy({client:c,path:{cityName:d},headers:_t,body:p}),"gc supervisor sling response was empty")},listMail(d,p){return pe(oy({client:c,path:{cityName:d},...p===void 0?{}:{query:p}}),"gc supervisor mail response was empty")},formulaFeed(d,p){return pe(ry({client:c,path:{cityName:d},...p===void 0?{}:{query:p}}),"gc supervisor formula feed response was empty")},sendMail(d,p){return pe(sy({client:c,path:{cityName:d},headers:_t,body:p}),"gc supervisor mail send response was empty")},mailThread(d,p){return pe(ay({client:c,path:{cityName:d,id:p}}),"gc supervisor mail thread response was empty")},markMailRead(d,p,m){return pe(fy({client:c,path:{cityName:d,id:p},headers:_t,...m===void 0?{}:{query:m}}),"gc supervisor mail mark-read response was empty")},markMailUnread(d,p,m){return pe(cy({client:c,path:{cityName:d,id:p},headers:_t,...m===void 0?{}:{query:m}}),"gc supervisor mail mark-unread response was empty")},archiveMail(d,p,m){return pe(uy({client:c,path:{cityName:d,id:p},headers:_t,...m===void 0?{}:{query:m}}),"gc supervisor mail archive response was empty")},replyMail(d,p,m,y){return pe(dy({client:c,path:{cityName:d,id:p},headers:_t,body:m,...y===void 0?{}:{query:y}}),"gc supervisor mail reply response was empty")},cityEventStreamUrl(d,p){return wf(l,`/v0/city/${encodeURIComponent(d)}/events/stream`,p===void 0?void 0:{after_seq:p})},sessionStreamUrl(d,p,m){return wf(l,`/v0/city/${encodeURIComponent(d)}/session/${encodeURIComponent(p)}/stream`,m===void 0?void 0:{after:m})},listSessions(d){return pe(yy({client:c,path:{cityName:d}}),"gc supervisor sessions response was empty")},sessionPending(d,p){return pe(my({client:c,path:{cityName:d,id:p}}),"gc supervisor session pending response was empty")},respondSession(d,p,m){return pe(hy({client:c,path:{cityName:d,id:p},headers:_t,body:m}),"gc supervisor session respond response was empty")},sessionTranscript(d,p){return pe(vy({client:c,path:{cityName:d,id:p},query:{format:"conversation"}}),"gc supervisor transcript response was empty")},workflowRun(d,p,m){return pe(Sy({client:c,path:{cityName:d,workflow_id:p},...m===void 0?{}:{query:m}}),"gc supervisor workflow response was empty")},formulaDetail(d,p,m){return pe(iy({client:c,path:{cityName:d,name:p},query:m}),"gc supervisor formula detail response was empty")},mutationHeaders(){return{..._t}}}}function Be(){return Sf??=td(),Sf}function Ny(r){const l=nd(r),s=Ef.get(l);if(s!==void 0)return s;const u=td({timeoutMs:l});return Ef.set(l,u),u}function nd(r){return typeof r=="number"&&Number.isFinite(r)&&r>0?r:Ry}function Ty(r,l){return async(s,u)=>{const c=new AbortController,d=new gn(void 0,`gc supervisor request timed out after ${l}ms`,void 0),p=Py(s,u);p?.aborted&&c.abort(p.reason);const m=()=>c.abort(p?.reason);p?.addEventListener("abort",m,{once:!0});let y;const x=new Promise((T,$)=>{y=setTimeout(()=>{c.abort(d),$(d)},l)}),k=new Request(s,{...u,signal:c.signal}),R=r(k);try{return await Promise.race([R,x])}finally{y!==void 0&&clearTimeout(y),p?.removeEventListener("abort",m)}}}function Py(r,l){return l?.signal!==void 0?l.signal:r instanceof Request?r.signal:null}async function Ay(r,l){const s=Gt("list agent pending interactions"),u=Ly(l),c=r.flatMap(p=>{const m=p.session?.name;if(m===void 0)return[];const y=u.get(m);return y===void 0?[]:[{agentName:p.name,sessionId:y,sessionName:m}]});return(await Promise.all(c.map(async p=>{const m=await Be().sessionPending(s,p.sessionId);return m.pending===void 0?null:{...p,pending:m.pending}}))).filter(p=>p!==null)}async function Ew(r,l){const s=Gt("respond to agent pending interaction");return Be().respondSession(s,r,l)}function xw(r){return`gc agent attach ${Iy(r)}`}function Ly(r){const l=new Map;for(const s of r)s.session_name!==void 0&&l.set(s.session_name,s.id);return l}function Iy(r){return/^[A-Za-z0-9_./:-]+$/.test(r)?r:`'${r.replaceAll("'","'\\''")}'`}const Oy=1e3,jy=200,My=1e3,Dy=new Set(["feature","bug","task","epic","chore","decision"]);async function zy(r={}){const l=Gt("list supervisor beads"),s=r.limit??Oy,u=r.rigFilter?.trim()??"",c=r.includeClosed??!1,d=r.includeBookkeeping??!1,p={limit:s,...c?{all:!0}:{},...u.length===0?{}:{rig:u}},m=await Be().listBeads(l,p),y=id(m.items??[]),x=c?y:y.filter(T=>T.status!=="closed"),k=d?x:x.filter($y),R=rd(m.total);return{items:k,total:k.length,...R===void 0?{}:{upstream_total:R},upstream_fetched:y.length,fetch_limit:s}}async function kw(r,l={}){const s=Gt("list supervisor assigned beads"),u=Uy(r),c=l.limit??jy,d=l.includeClosed??!1;if(u.length===0)return{items:[],total:0,upstream_fetched:0,fetch_limit:c};const p=await Promise.all(u.map(x=>Be().listBeads(s,{assignee:x,limit:c,...d?{all:!0}:{}}))),m=id(p.flatMap(x=>x.items??[])),y=By(p);return{items:m,total:m.length,...y===void 0?{}:{upstream_total:y},upstream_fetched:m.length,fetch_limit:c}}async function Cw(r){const l=Gt("fetch supervisor bead");try{return await Be().getBead(l,r)}catch(s){if(!(s instanceof gn)||s.status!==404)throw s;const c=((await Be().listBeads(l,{limit:My})).items??[]).find(d=>d.id===r);if(c!==void 0)return c;throw s}}function $y(r){return!(!Dy.has(r.issue_type)||Array.isArray(r.labels)&&r.labels.some(l=>l.startsWith("gc:")))}function rd(r){if(typeof r=="number")return r;if(typeof r=="bigint")return Number(r)}function By(r){let l=0;for(const s of r){const u=rd(s.total);if(u===void 0)return;l+=u}return l}function id(r){const l=new Set,s=[];for(const u of r)l.has(u.id)||(l.add(u.id),s.push(u));return s}function Uy(r){const l=new Set,s=[];for(const u of r){const c=u.trim();c.length===0||l.has(c)||(l.add(c),s.push(c))}return s}const _w=[100,500,1e3],Ks=100,Rw=["24h","7d","all"],Fy="all",Vy={"24h":1440*60*1e3,"7d":10080*60*1e3};async function Xs(r,l,s,u=Ks,c=Fy,d=Date.now()){const p=Gt("list supervisor mail"),m=await Be().listMail(p,{limit:u}),y=m.items??[],x=Hy(Wy(y,r,l,s),c,d);return x.sort(Gy),{...m,items:x,total:x.length,upstream_total:y.length,upstream_fetched:y.length,fetch_limit:u}}async function Nw(r,l,s,u=Ks){const c=Gt("fetch supervisor mail thread");try{const d=await Be().mailThread(c,r);return xf(d)}catch(d){if(!(d instanceof gn)||d.status!==404)throw d;const p=await Xs("all",l,s,u),m=p.items.filter(y=>y.thread_id===r);return xf({...p,items:m,total:m.length})}}function xf(r){const l=Yy(r.items??[]).sort(qy);return{...r,items:l,total:l.length}}function Wy(r,l,s,u){const c=Qy(s,u);return l==="all"?[...r]:l==="inbox"?r.filter(d=>d.to.toLowerCase()===c):r.filter(d=>d.from.toLowerCase()===c)}function Hy(r,l,s){if(l==="all")return[...r];const u=s-Vy[l];return r.filter(c=>{const d=Date.parse(c.created_at);return Number.isFinite(d)&&d>=u})}function Qy(r,l){const s=r.toLowerCase();return s===l.operatorAlias.toLowerCase()?l.operatorWireAlias:s}function Yy(r){const l=new Set,s=[];for(const u of r)l.has(u.id)||(l.add(u.id),s.push(u));return s}function Gy(r,l){return l.created_at.localeCompare(r.created_at)}function qy(r,l){return r.created_at.localeCompare(l.created_at)}function ld(r,l){if(r===void 0||r.length===0)return null;const s=Date.parse(r);if(!Number.isFinite(s))return null;const u=l-s;return u>=0?u:null}function od(r){const l=Math.max(1,Math.round(r/36e5));return l<48?`${l}h`:`${Math.round(l/24)}d`}const Ky=1440*60*1e3,Xy=4320*60*1e3;function Jy(r,l){const s=[];for(const u of r.escalations){const c=Zy(u);c!==null&&s.push(c)}for(const u of r.beads){const c=by(u,l);c!==null&&s.push(c)}return s}function Zy(r){return r.status==="closed"?null:{beadId:r.id,reason:"escalated",severity:"attention",summary:`${r.title} — escalation raised`,updatedAt:r.updated_at??r.created_at}}function by(r,l){if(r.status!=="open"||eg(r))return null;const s=ld(r.created_at,l);if(s===null||s=Xy;return{beadId:r.id,reason:"ready-unclaimed",severity:u?"attention":"watch",summary:`${r.title} opened ${od(s)} ago`,updatedAt:r.created_at}}function eg(r){return r.assignee!==void 0&&r.assignee.trim().length>0}function kf(r,l){const s=`/runs/${encodeURIComponent(r)}`;if(l.status!=="available")return s;const u=new URLSearchParams;return u.set("scope_kind",l.kind),u.set("scope_ref",l.ref),`${s}?${u.toString()}`}const tg={"awaiting-input":"awaiting input",errored:"errored","rate-limited":"rate limited",stalled:"stalled"},ng={respond:"Respond to its prompt.",reset:"Reset the agent.",nudge:"Nudge it to resume."},rg={"awaiting-input":"stuck",errored:"stuck","rate-limited":"warn",stalled:"warn"};function ig(r){return tg[r]}function Tw(r){return ng[r]}function Pw(r){return rg[r]}const lg=new Set(["gc.store.maintenance.failed","order.failed","request.failed","session.crashed","session.stranded","session.work_query_failed","supervisor.shutdown_requested"]),og=new Set(["events.rotated","session.quarantined","session.suspended","supervisor.fs_pressure.skipped_tick"]);function sg(r){return lg.has(r.type)?"attention":og.has(r.type)?"watch":"event"}function ag(r){return r.message??r.subject??r.type}const ug=1440*60*1e3,cg=30,fg=2e9,dg=1e9,pg=1e9,mg=512e6,hg="gc:escalation",vg="decision.decide";function yg(r={}){return ii.map(l=>gg(l,r))}function gg(r,l){switch(r){case"activity":return Cg(l.activity);case"agents":return Eg(l.agents);case"beads":return xg(l.beads);case"health":return wg(l.health);case"mail":return kg(l.mail);case"runs":return Sg(l.runs)}}function wg(r){return{id:"health:derived",domain:"health",getItems:()=>Dg(r)}}function Sg(r){return{id:"runs:derived",domain:"runs",getItems:()=>_g(r)}}function Eg(r){return{id:"agents:derived",domain:"agents",getItems:()=>Rg(r)}}function xg(r){return{id:"beads:derived",domain:"beads",getItems:()=>Ng(r)}}function kg(r){return{id:"mail:derived",domain:"mail",getItems:()=>Lg(r)}}function Cg(r){return{id:"activity:derived",domain:"activity",getItems:()=>Og(r)}}function _g(r){const l=[];if(r===void 0)return l;const s={provenance:r.provenance,fetchedAt:r.fetchedAt};if(r.error!==void 0&&r.error.length>0)return l.push(nt("runs",{id:"runs:unavailable",title:"Run data unavailable",summary:r.error,href:"/runs"})),l;const u=r.summary;if(u===void 0)return l;u.lanesPartial===!0&&l.push(ei("runs",{id:"runs:partial",title:"Run list incomplete",href:"/runs"},s));for(const c of[...u.lanes,...u.blockedLanes])c.health.status!=="available"&&l.push(ei("runs",{id:`runs:${c.id}:health-unavailable`,title:`${c.title} health unavailable`,summary:c.health.error,href:kf(c.id,c.scope)},s));for(const c of Gh(u.blockedLanes))l.push(nt("runs",{id:`runs:${c.id}:blocked`,title:`${c.title} blocked`,summary:c.reason,href:kf(c.id,c.scope)}));return l}function Rg(r){const l=[];if(r===void 0)return l;if(r.error!==void 0&&r.error.length>0)return l.push(ei("agents",{id:"agents:unavailable",title:"Agent data unavailable",summary:r.error,href:"/agents"})),l;r.partial===!0&&l.push(ei("agents",{id:"agents:partial",title:"Agent list incomplete",href:"/agents"})),r.pendingError!==void 0&&r.pendingError.length>0&&l.push(ei("agents",{id:"agents:pending-unavailable",title:"Agent pending state unavailable",summary:r.pendingError,href:"/agents"}));const s=(r.pendingInteractions??[]).map(u=>({agentName:u.agentName,...u.pending.prompt===void 0?{}:{prompt:u.pending.prompt}}));for(const u of Vh(r.items??[],s))l.push(nt("agents",{id:`agents:${u.name}:needs-you`,title:`${u.name} ${ig(u.reason)}`,summary:u.detail,href:`/agents/${encodeURIComponent(u.name)}`}));return l}function Ng(r){const l=[];if(r===void 0)return l;r.error!==void 0&&r.error.length>0&&l.push(nt("beads",{id:"beads:unavailable",title:"Bead data unavailable",summary:r.error,href:"/beads"})),r.partial===!0&&l.push(vn("beads",{id:"beads:partial",title:"Bead list incomplete",href:"/beads"})),r.decisionsError!==void 0&&r.decisionsError.length>0&&l.push(nt("beads",{id:"beads:decisions-unavailable",title:"Decision queue unavailable",summary:r.decisionsError,href:"/beads"})),r.escalationsError!==void 0&&r.escalationsError.length>0&&l.push(nt("beads",{id:"beads:escalations-unavailable",title:"Escalation queue unavailable",summary:r.escalationsError,href:"/beads"}));for(const c of r.decisions??[])l.push(Ag(c));const s=r.nowMs??Date.now(),u=(r.items??[]).filter(c=>!Pg(c,r.decisionLabel));for(const c of Jy({beads:u,escalations:r.escalations??[]},s)){const d=c.severity==="attention"?nt:vn;l.push(d("beads",{id:`beads:${c.beadId}:${c.reason}`,title:`${c.beadId} ${Tg(c.reason)}`,summary:c.summary,href:sd(c.beadId),updatedAt:c.updatedAt}))}return l}function Tg(r){return r==="escalated"?"escalated":"unclaimed"}function sd(r){const l=new URLSearchParams;return l.set("bead",r),`/beads?${l.toString()}`}function Pg(r,l){return(r.labels??[]).includes(l)}function Ag(r){const l=r.metadata?.[vg];return nt("beads",{id:`beads:${r.id}:mayor-decision`,title:r.title,href:sd(r.id),updatedAt:r.updated_at??r.created_at,...l!==void 0&&l.trim().length>0?{summary:l}:{}})}function Lg(r){const l=[];if(r===void 0)return l;r.error!==void 0&&r.error.length>0&&l.push(nt("mail",{id:"mail:unavailable",title:"Mail data unavailable",summary:r.error,href:"/mail"})),r.partial===!0&&l.push(vn("mail",{id:"mail:partial",title:"Mail list incomplete",href:"/mail"}));const s=r.nowMs??Date.now();for(const u of tv(r.items??[])){const c=ld(u.created_at,s),d=c!==null&&c>=ug;l.push(nt("mail",{id:`mail:${u.id}:${d?"unread-stale":"unread"}`,title:u.subject,summary:d?`from ${u.from}, unread for ${od(c)}`:`from ${u.from}`,href:Ig(u.id),updatedAt:u.created_at}))}return l}function Ig(r){const l=new URLSearchParams;return l.set("message",r),`/mail?${l.toString()}`}function Og(r){const l=[];if(r===void 0)return l;r.deploysError!==void 0&&r.deploysError.length>0&&l.push(nt("activity",{id:"activity:deploys-unavailable",title:"Deploy data unavailable",summary:r.deploysError,href:"/activity"})),r.eventsDegraded!==void 0&&r.eventsDegraded.length>0&&l.push(vn("activity",{id:"activity:events-degraded",title:"Event stream degraded",summary:r.eventsDegraded,href:"/activity"})),r.eventsError!==void 0&&r.eventsError.length>0&&l.push(vn("activity",{id:"activity:events-unavailable",title:"Event history unavailable",summary:r.eventsError,href:"/activity"})),r.eventsPartial===!0&&l.push(vn("activity",{id:"activity:events-partial",title:"Event history incomplete",href:"/activity"})),jg(l,r.events??[]);const s=r.deploys;if(s===void 0)return l;s.failed_marker&&l.push(nt("activity",{id:"activity:failed-marker",title:"Deploy failed marker present",href:"/activity"}));for(const u of s.items)u.status==="failed"?l.push(nt("activity",{id:`activity:deploy:${u.at}:failed`,title:"Deploy failed",summary:u.detail,href:"/activity",updatedAt:u.at})):u.status==="in-progress"&&l.push(vn("activity",{id:`activity:deploy:${u.at}:in-progress`,title:"Deploy in progress",summary:u.detail,href:"/activity",updatedAt:u.at}));return l}function jg(r,l){for(const s of l){const u=sg(s);if(u==="event")continue;const c=u==="attention"?nt:vn;r.push(c("activity",{id:`activity:event:${String(s.seq)}:${s.type}`,title:s.type,summary:ag(s),href:Mg(s),updatedAt:s.ts}))}}function Mg(r){return`/activity?${new URLSearchParams({mode:"events",type:r.type}).toString()}`}function Dg(r){const l=[];return r===void 0||(r.dashboardError!==void 0&&r.dashboardError.length>0&&l.push(wn({id:"health:dashboard-health-unavailable",title:"Dashboard health unavailable",summary:r.dashboardError})),r.supervisor!==void 0&&zg(l,r.supervisor),r.system!==void 0&&($g(l,r.system),Bg(l,r.system)),r.trend!==void 0&&!r.trend.available&&l.push(jn({id:"health:dolt-noms-unavailable",title:"Dolt-noms trend unavailable",summary:r.trend.reason}))),l}function zg(r,l){if(l.status==="unavailable"){r.push(wn({id:"health:supervisor-unreachable",title:"Supervisor unreachable",summary:l.error}));return}const s=l.data;s.status!=="ok"&&r.push(wn({id:"health:supervisor-not-ok",title:`Supervisor ${s.status}`})),s.city===void 0&&r.push(jn({id:"health:supervisor-city-missing",title:"Supervisor city missing",summary:"city was absent from generated supervisor health"})),s.version===void 0&&r.push(jn({id:"health:supervisor-version-missing",title:"Supervisor version missing",summary:"version was absent from generated supervisor health"}))}function $g(r,l){const s=l.admin;s.uptime_sec=fg?r.push(wn({id:"health:dashboard-process-rss-high",title:"Dashboard RSS high",summary:gl(s.rss_bytes)})):s.rss_bytes>=dg&&r.push(jn({id:"health:dashboard-process-rss-elevated",title:"Dashboard RSS elevated",summary:gl(s.rss_bytes)})),s.heap_used_bytes>=pg?r.push(wn({id:"health:dashboard-process-heap-high",title:"Dashboard heap high",summary:gl(s.heap_used_bytes)})):s.heap_used_bytes>=mg&&r.push(jn({id:"health:dashboard-process-heap-elevated",title:"Dashboard heap elevated",summary:gl(s.heap_used_bytes)}))}function Bg(r,l){const s=Cf(l.host.free_mem_bytes,l.host.total_mem_bytes);s!==null&&s<.05?r.push(wn({id:"health:memory-critical",title:"Host memory critical",summary:`${Math.round(s*100)}% free`})):s!==null&&s<.1&&r.push(jn({id:"health:memory-low",title:"Host memory low",summary:`${Math.round(s*100)}% free`}));const u=Cf(l.host.load_avg_1,l.host.cpu_count);u!==null&&u>1.5?r.push(wn({id:"health:load-high",title:"Host load high",summary:`${l.host.load_avg_1.toFixed(2)} load across ${l.host.cpu_count} CPUs`})):u!==null&&u>1&&r.push(jn({id:"health:load-elevated",title:"Host load elevated",summary:`${l.host.load_avg_1.toFixed(2)} load across ${l.host.cpu_count} CPUs`}))}function gl(r){return r>=1e9?`${(r/1e9).toFixed(1)} GB`:r>=1e6?`${Math.round(r/1e6)} MB`:r>=1e3?`${Math.round(r/1e3)} KB`:`${r} B`}function Cf(r,l){return l<=0?null:r/l}function wn(r){return{domain:"health",severity:"attention",href:"/health",current:!0,actionable:!0,...r}}function nt(r,l){return{domain:r,severity:"attention",current:!0,actionable:!0,...l}}function vn(r,l){return{domain:r,severity:"watch",current:!0,actionable:!1,...l}}function ei(r,l,s){return{domain:r,severity:"unavailable",current:!0,actionable:!1,...l,...s?.provenance===void 0?{}:{provenance:s.provenance},...s?.fetchedAt===void 0?{}:{fetchedAt:s.fetchedAt}}}function jn(r){return{domain:"health",severity:"watch",href:"/health",current:!0,actionable:!1,...r}}const Ug=1e3,Fg=100,Vg="24h",Wg=2500;function Hg(r,l){const s=Cl(),u=s??"no-city",{decisionLabel:c,operatorWireAlias:d}=r,p=w.useMemo(()=>Qg(l),[l]),m=Vt(`attention:agents:${u}`,()=>Yg(s)),y=Vt(`attention:beads:${u}:${c}`,()=>Gg(s,c)),x=Vt(`attention:mail:${u}:${d}`,()=>Xg(s,r)),k=Vt(`attention:activity:${u}`,()=>Jg(s)),R=Vt(`attention:health:${u}`,()=>Zg(s));return w.useMemo(()=>yg(bg({activity:k.data,agents:m.data,beads:y.data,health:R.data,mail:x.data,runs:p})),[k.data,m.data,y.data,R.data,x.data,p])}function Qg(r){if(r!==void 0)return r.status==="error"?{error:r.error,provenance:"error"}:{summary:r.data,provenance:r.status,fetchedAt:r.fetchedAt}}async function Yg(r){if(r===null)return{};try{const l=await Be().listAgents(r),s={items:l.items??[],partial:l.partial===!0};try{const u=await Be().listSessions(r);s.pendingInteractions=await Ay(l.items??[],u.items??[])}catch(u){s.pendingError=Rt(u,"agent pending state unavailable")}return s}catch(l){return{error:Rt(l,"agent list unavailable")}}}async function Gg(r,l){if(r===null)return{decisionLabel:l};const[s,u,c]=await Promise.allSettled([zy({limit:Ug}),qg(r,l),Kg(r)]),d={nowMs:Date.now(),decisionLabel:l};return s.status==="fulfilled"?(d.items=s.value.items,d.partial=s.value.partial===!0):d.error=Rt(s.reason,"bead list unavailable"),u.status==="fulfilled"?d.decisions=u.value.items??[]:d.decisionsError=Rt(u.reason,"decision queue unavailable"),c.status==="fulfilled"?d.escalations=c.value.items??[]:d.escalationsError=Rt(c.reason,"escalation queue unavailable"),d}async function qg(r,l){return Be().listBeads(r,{label:l,status:"open"})}async function Kg(r){return Be().listBeads(r,{label:hg,status:"open"})}async function Xg(r,l){if(r===null)return{};try{const s=await Xs("inbox",l.operatorAlias,l,Ks);return{items:s.items??[],nowMs:Date.now(),partial:s.partial===!0}}catch(s){return{error:Rt(s,"mail list unavailable")}}}async function Jg(r){const[l,s]=await Promise.allSettled([fr.listBuilds(),r===null?Promise.resolve(null):Be().listEvents(r,{limit:Fg,since:Vg})]),u={};return l.status==="fulfilled"?u.deploys=l.value:u.deploysError=Rt(l.reason,"deploy activity unavailable"),s.status==="fulfilled"?s.value!==null&&(u.events=s.value.items??[],u.eventsPartial=s.value.partial===!0,s.value.partial_errors!==null&&s.value.partial_errors!==void 0&&(u.eventsDegraded=s.value.partial_errors.join("; "))):u.eventsError=Rt(s.reason,"event history unavailable"),u}async function Zg(r){if(r===null)return{};const[l,s,u]=await Promise.allSettled([fr.systemHealth(),Ny(Wg).cityHealth(r),fr.doltTrend()]),c={},d=[];return l.status==="fulfilled"?c.system=l.value:d.push(Rt(l.reason,"dashboard health unavailable")),s.status==="fulfilled"?c.supervisor={status:"available",data:s.value}:c.supervisor={status:"unavailable",error:Rt(s.reason,"supervisor health unavailable")},u.status==="fulfilled"?c.trend=u.value:d.push(Rt(u.reason,"dolt-noms trend unavailable")),d.length>0&&(c.dashboardError=d.join("; ")),c}function bg(r){const l={};for(const[s,u]of Object.entries(r))u!==void 0&&(l[s]=u);return l}async function ar(r){const l={Accept:"application/json","Content-Type":"application/json","X-GC-Request":"dashboard"};try{const s=await fetch("/api/client-errors",{method:"POST",headers:l,credentials:"same-origin",keepalive:!0,body:JSON.stringify(r)});return s.ok?{status:"reported"}:{status:"failed",error:`client error report failed with ${s.status}`}}catch(s){return{status:"failed",error:sr(s)}}}class ad extends w.Component{state={crashed:!1};static getDerivedStateFromError(){return{crashed:!0}}componentDidCatch(l,s){ar({component:"ErrorBoundary",operation:"componentDidCatch",message:sr(l)})}render(){return this.state.crashed?N.jsx("main",{className:"max-w-dashboard mx-auto px-4 sm:px-6 lg:px-8 py-12",children:N.jsxs("section",{className:"space-y-4",role:"alert",children:[N.jsx("h1",{className:"text-display font-semibold text-fg",children:"Dashboard view failed."}),N.jsx("p",{className:"text-body text-fg-muted",children:"The error was reported to the local dashboard log. Refresh to retry this view."})]})}):this.props.children}}function e0({label:r,summary:l}){const s=l.attention+l.watch;if(s===0||l.severity===null)return null;const u=s===1?"item":"items";return N.jsx("span",{"aria-label":`${r}: ${s} ${l.severity} ${u}`,className:`ml-1 align-super text-[0.65rem] leading-none tnum ${t0(l.severity)}`,children:s})}function t0(r){return r==="attention"?"text-accent":"text-warn"}function ud(r,l,s){try{const u=Js(r).getItem(l);return u===null?{status:"missing"}:{status:"found",value:u}}catch(u){return Zs(r,"getItem",l,s,u)}}function cd(r,l,s,u){try{return Js(r).setItem(l,s),{status:"stored"}}catch(c){return Zs(r,"setItem",l,u,c)}}function fd(r,l,s){try{return Js(r).removeItem(l),{status:"stored"}}catch(u){return Zs(r,"removeItem",l,s,u)}}function Js(r){return r==="localStorage"?window.localStorage:window.sessionStorage}function Zs(r,l,s,u,c){const d=sr(c);return ar({component:u,operation:`${r}.${l}`,message:`${s}: ${d}`}),{status:"unavailable",error:d}}const Us="gascity:theme",Fs="ThemeContext",dd=w.createContext(null);function n0(){const r=ud("localStorage",Us,Fs);return r.status==="found"&&(r.value==="light"||r.value==="dark")?r.value:"system"}function r0(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function i0(r){const l=document.documentElement;r==="system"?l.removeAttribute("data-theme"):l.setAttribute("data-theme",r)}function l0({children:r}){const[l,s]=w.useState(n0),[u,c]=w.useState(r0);w.useEffect(()=>{const x=window.matchMedia("(prefers-color-scheme: dark)"),k=()=>c(x.matches?"dark":"light");return x.addEventListener("change",k),()=>x.removeEventListener("change",k)},[]);const d=l==="system"?u:l,p=w.useCallback(x=>{s(x),x==="system"?fd("localStorage",Us,Fs):cd("localStorage",Us,x,Fs),i0(x)},[]),m=w.useCallback(()=>{p(d==="dark"?"light":"dark")},[d,p]),y=w.useMemo(()=>({pref:l,resolved:d,set:p,toggle:m}),[l,d,p,m]);return N.jsx(dd.Provider,{value:y,children:r})}function o0(){const r=w.useContext(dd);if(r===null)throw new Error("useTheme must be used inside ");return r}const pd={operatorAlias:"operator",operatorWireAlias:"human",decisionLabel:"needs/operator"},md=w.createContext(pd);function s0({operator:r,children:l}){return N.jsx(md.Provider,{value:r,children:l})}function hd(){return w.useContext(md)}function a0(r){return r===void 0?pd:{operatorAlias:r.operatorAlias,operatorWireAlias:r.operatorWireAlias,decisionLabel:r.decisionLabel}}const u0={ok:"text-ok",warn:"text-warn",stuck:"text-accent",neutral:"text-fg-muted"},c0={ok:"●",warn:"▲",stuck:"■",neutral:"·"};function f0({tone:r,label:l,glyph:s,trailing:u,className:c="",title:d}){return N.jsxs("span",{className:`inline-flex items-baseline gap-1.5 text-body ${u0[r]} ${c}`,title:d,children:[N.jsx("span",{"aria-hidden":!0,className:"text-[0.85em] leading-none translate-y-[1px]",children:s??c0[r]}),N.jsx("span",{children:l}),u&&N.jsx("span",{className:"text-fg-faint text-label uppercase tracking-wider",children:u})]})}function Aw(r){switch(r){case"closed":return"neutral";case"in_progress":return"ok";case"blocked":return"stuck";default:return"warn"}}function Lw(r){switch(r){case"active":case"running":return"ok";case"rate-limited":case"rate_limited":case"waiting":return"warn";case"failed":case"closed":case"errored":case"stuck":return"stuck";default:return"neutral"}}const vd=w.createContext(!1);function d0({readOnly:r,children:l}){return N.jsx(vd.Provider,{value:r,children:l})}function p0(){return w.useContext(vd)}function m0(r,l){return r?r.readOnly:l!==null}const yd="Read-only mode: mutations are disabled";function Iw(){return N.jsx(f0,{tone:"warn",label:"Read-only",title:yd})}const h0="mayor";function v0(r){const{operator:l,sessionAliases:s,mailFromOrTo:u}=r,c=new Map;for(const $ of s){const z=$.toLowerCase();c.has(z)||c.set(z,$)}for(const $ of u){const z=$.toLowerCase();c.has(z)||c.set(z,$)}const d=l.toLowerCase(),p=new Set(u.map($=>$.toLowerCase())),m=[l],y=[],x=[],k=[];for(const[$,z]of c)if($!==d){if($===h0){y.push(z);continue}p.has($)?x.push(z):k.push(z)}const R=($,z)=>$.toLowerCase().localeCompare(z.toLowerCase());x.sort(R),k.sort(R);const T=[{tier:"you",aliases:m}];return y.length>0&&T.push({tier:"mayor",aliases:y}),x.length>0&&T.push({tier:"active",aliases:x}),k.length>0&&T.push({tier:"other",aliases:k}),T}function y0(r,l){return r===l?"user":r}function Ow(r){switch(r){case"you":return"You";case"mayor":return"Mayor";case"active":return"Active";case"other":return"Other"}}async function g0(){return Be().listSessions(Gt("list supervisor sessions"))}async function jw(r){const l=await Be().sessionTranscript(Gt("fetch supervisor session transcript"),r);return S0(l)}function Mw(r){return(r.items??[]).map(w0)}function w0(r){const l={id:r.id,template:r.template,session_name:r.session_name,title:r.title,state:r.state,created_at:r.created_at,attached:r.attached,running:r.running,provider:r.provider};return r.alias!==void 0&&(l.alias=r.alias),r.reason!==void 0&&(l.reason=r.reason),r.display_name!==void 0&&(l.display_name=r.display_name),r.last_active!==void 0&&(l.last_active=r.last_active),r.rig!==void 0&&(l.rig=r.rig),r.pool!==void 0&&(l.pool=r.pool),r.agent_kind!==void 0&&(l.agent_kind=r.agent_kind),r.model!==void 0&&(l.model=r.model),r.context_pct!==void 0&&(l.context_pct=r.context_pct),r.context_window!==void 0&&(l.context_window=r.context_window),r.activity!==void 0&&(l.activity=r.activity),l}function S0(r,l=new Date().toISOString()){const s=r.turns??[];return{...r,turns:s,total_chars:s.reduce((u,c)=>u+c.text.length,0),captured_at:l,truncated:!1}}const Vs="gascity.dashboard.viewingAs",ur="ViewingAsContext",_f=/^[a-z][a-z0-9_./-]{1,63}$/i,Rf=[3e4,9e4,27e4];function E0(r){if(!Number.isInteger(r)||r<0||r>=Rf.length)return null;const l=Rf[r];return l===void 0?null:l}const gd=w.createContext(null);function Nf(r){const l=ud("sessionStorage",Vs,ur);if(l.status==="found"){const s=l.value;if(s.length>0&&s.length<=64)return s}return r}function Is(r,l){r===l?fd("sessionStorage",Vs,ur):cd("sessionStorage",Vs,r,ur)}function x0({children:r}){const l=hd(),{operatorAlias:s}=l,[u,c]=w.useState(()=>Nf(s)),d=w.useRef(s),[p,m]=w.useState([]),[y,x]=w.useState([]),[k,R]=w.useState(!1),[T,$]=w.useState(!1),z=w.useRef(!1),M=w.useRef(!0),P=w.useRef(null),D=w.useCallback(ne=>{c(ne),Is(ne,s)},[s]),G=w.useCallback(()=>{c(s),Is(s,s)},[s]),Y=w.useCallback(async()=>{try{const ne=await g0();if(!M.current)return!0;const ye=new Set,oe=[];for(const _e of ne.items??[]){if(typeof _e.alias!="string"||!_f.test(_e.alias))continue;const Le=_e.alias.toLowerCase();ye.has(Le)||(ye.add(Le),oe.push(_e.alias))}return m(oe),$(!1),!0}catch(ne){return ar({component:ur,operation:"loadAliases.sessions",message:sr(ne)}),!1}},[]),q=w.useCallback(ne=>{if(!M.current)return;const ye=E0(ne);ye!==null&&(P.current=setTimeout(()=>{P.current=null,M.current&&Y().then(oe=>{M.current&&(oe||q(ne+1))}).catch(oe=>{ar({component:ur,operation:"loadAliases.sessionsRetry",message:sr(oe)})})},ye))},[Y]),b=w.useCallback(()=>{if(z.current)return;z.current=!0,R(!0);let ne=2;const ye=()=>{ne-=1,ne===0&&M.current&&R(!1)};Y().then(oe=>{M.current&&(oe||($(!0),q(0)))}).finally(ye),Xs("all",s,l).then(oe=>{if(!M.current)return;const _e=new Set,Le=[];for(const Me of oe.items)for(const Ie of[Me.from,Me.to]){if(typeof Ie!="string"||Ie.length===0||!_f.test(Ie))continue;const rt=Ie.toLowerCase();_e.has(rt)||(_e.add(rt),Le.push(Ie))}x(Le)}).catch(oe=>{ar({component:ur,operation:"loadAliases.mail",message:sr(oe)})}).finally(ye)},[Y,q,s,l]);w.useEffect(()=>(M.current=!0,()=>{M.current=!1,P.current!==null&&(clearTimeout(P.current),P.current=null)}),[]),w.useEffect(()=>{const ne=d.current;d.current=s,ne!==s&&u===ne&&c(Nf(s))},[s,u]);const ee=w.useMemo(()=>v0({operator:s,sessionAliases:p.includes(u)?p:[...p,u],mailFromOrTo:y}),[p,y,u,s]),te=w.useMemo(()=>({viewingAs:{alias:u,isOperator:u===s},setAlias:D,resetToOperator:G,aliasBuckets:ee,aliasesLoading:k,sessionsUnavailable:T,loadAliases:b}),[u,s,D,G,ee,k,T,b]);return w.useEffect(()=>{const ne=()=>{document.hidden&&u!==s&&(c(s),Is(s,s))};return document.addEventListener("visibilitychange",ne),()=>document.removeEventListener("visibilitychange",ne)},[u,s]),N.jsx(gd.Provider,{value:te,children:r})}function k0(){const r=w.useContext(gd);if(r===null)throw new Error("useViewingAs must be inside ");return r}const C0={id:"activity",kind:"core",path:"/activity",nav:{label:"Activity",order:55},element:w.lazy(()=>Yt(()=>import("./Activity-odvexmkt.js"),__vite__mapDeps([0,1,2,3,4])).then(r=>({default:r.ActivityPage})))},_0={id:"health",kind:"core",path:"/health",nav:{label:"Health",order:60},element:w.lazy(()=>Yt(()=>import("./Health-Dtx4mLLZ.js"),__vite__mapDeps([5,1,2,4,6,3])).then(r=>({default:r.HealthPage})))},wd=[C0,_0],R0={views:"views"};function N0(r,l){console.warn(`[${r}] ${l}`)}function Sd(r,l){const s=new Set(l??[]);return r.filter(u=>u.kind==="core"||s.has(u.id))}const T0={};function P0(r,l){const s=[];if(l!==null){const p=T0[l];if(p!==void 0){if(r.some(y=>y.id===p.target))return{view:null,redirectTo:p.redirectTo,source:"env",warnings:s};s.push(`DEFAULT_VIEW="${l}" alias targets the "${p.target}" view, which is not enabled in this deployment (known enabled ids: ${r.map(y=>y.id).join(", ")||"(none)"}); falling through to descriptor / ambient-home`)}else{const m=r.find(y=>y.id===l);if(m!==void 0)return{view:m,source:"env",warnings:s};s.push(`DEFAULT_VIEW="${l}" does not match any enabled view (known enabled ids: ${r.map(y=>y.id).join(", ")||"(none)"}); falling through to descriptor / ambient-home`)}}const u=r.filter(p=>p.defaultRoute===!0),[c,...d]=u;if(c!==void 0&&d.length===0)return{view:c,source:"descriptor",warnings:s};if(c!==void 0){const m=[...u].sort(L0)[0]??c;return s.push(`multiple views declare defaultRoute: true (${u.map(y=>y.id).join(", ")}); picking "${m.id}" by lowest nav.order`),{view:m,source:"descriptor",warnings:s}}return{view:null,source:"fallback",warnings:s}}function A0(r,l){const s=P0(r,l);for(const u of s.warnings)N0(R0.views,u);return s}function L0(r,l){const s=r.nav?.order??Number.POSITIVE_INFINITY,u=l.nav?.order??Number.POSITIVE_INFINITY;return s!==u?s-u:r.id.localeCompare(l.id)}const I0=[{to:"/",label:"Home",end:!0,order:10},{to:"/agents",label:"Agents",order:20},{to:"/beads",label:"Beads",order:30},{to:"/runs",label:"Runs",order:40},{to:"/mail",label:"Mail",order:50}],O0={"/agents":"agents","/beads":"beads","/runs":"runs","/mail":"mail","/activity":"activity","/health":"health"};function j0(){const{resolved:r,toggle:l}=o0(),{viewingAs:s}=k0(),{operatorAlias:u}=hd(),c=p0(),d=Ov(),{data:p}=Vt("config",()=>fr.config()),{data:m}=Vt("cities",()=>Be().listCities()),y=Cl(),x=m?.items??[],k=y??p?.cityName??"",R=k===""||x.some(D=>D.name===k),T=x.length>1||!R,$=D=>{D!==y&&window.location.assign(`/city/${encodeURIComponent(D)}/`)},z=w.useMemo(()=>{const G=Sd(wd,p?.enabledModules??null).flatMap(Y=>Y.nav===null?[]:[{to:Y.path,label:Y.nav.label,end:Y.path==="/",order:Y.nav.order}]);return[...I0,...G].sort((Y,q)=>Y.order-q.order)},[p?.enabledModules]),{pathname:M}=Qt(),P=!s.isOperator&&M.startsWith("/mail");return N.jsx("header",{className:"border-b border-rule",children:N.jsxs("div",{className:"max-w-dashboard mx-auto px-4 sm:px-6 lg:px-8 py-5 flex items-baseline gap-x-6 lg:gap-x-8 gap-y-2 flex-wrap",children:[N.jsxs("div",{className:"flex items-baseline gap-3 min-w-0",children:[N.jsx("span",{className:"text-title font-semibold tracking-tight text-fg",children:"gas city"}),N.jsx("span",{className:"text-fg-muted","aria-hidden":"true",children:"·"}),T?N.jsx("label",{className:"sr-only",htmlFor:"city-switcher",children:"Switch city"}):null,T?N.jsxs("select",{id:"city-switcher",value:k,onChange:D=>$(D.target.value),className:"text-label uppercase tracking-wider text-fg-muted bg-transparent border-0 focus-mark cursor-pointer hover:text-fg transition-colors duration-150 ease-out-quart",children:[!R&&k!==""?N.jsxs("option",{value:k,disabled:!0,children:[k," (unknown)"]}):null,x.map(D=>N.jsxs("option",{value:D.name,children:[D.name,D.running?"":" (stopped)"]},D.name))]}):N.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:k||"city"}),P&&N.jsxs("span",{className:"text-label uppercase tracking-wider text-accent ml-3",children:["· reading as ",y0(s.alias,u)]}),c&&N.jsx("span",{title:yd,className:"text-label uppercase tracking-wider text-warn ml-3",children:"· read-only"})]}),N.jsx("nav",{className:"flex-1",children:N.jsx("ul",{className:"flex items-baseline gap-x-5 lg:gap-x-7 gap-y-1 flex-wrap",children:z.map(D=>{const G=O0[D.to];return N.jsx("li",{children:N.jsxs(Mh,{to:D.to,end:D.end??!1,className:({isActive:Y})=>["text-title transition-colors duration-150 ease-out-quart focus-mark",Y?"text-fg font-semibold":"text-fg-muted font-medium hover:text-fg"].join(" "),children:[D.label,G!==void 0&&N.jsx(e0,{label:D.label,summary:d.byDomain[G]})]})},D.to)})})}),N.jsx("button",{type:"button",onClick:l,"aria-label":`Switch to ${r==="dark"?"light":"dark"} theme`,className:"text-label uppercase tracking-wider text-fg-muted hover:text-fg transition-colors duration-150 ease-out-quart focus-mark",children:r==="dark"?"Light":"Dark"})]})})}function M0({children:r}){return N.jsxs("div",{className:"min-h-screen bg-surface text-fg antialiased",children:[N.jsx(j0,{}),N.jsx("main",{className:"max-w-dashboard mx-auto px-4 sm:px-6 lg:px-8 py-12",children:r})]})}const Ed=w.createContext(null);function D0({children:r,intervalMs:l=1e3}){const[s,u]=w.useState(()=>Date.now());return w.useEffect(()=>{const c=window.setInterval(()=>{u(Date.now())},l);return()=>{window.clearInterval(c)}},[l]),N.jsx(Ed.Provider,{value:s,children:r})}function Dw(){const r=w.useContext(Ed);if(r===null)throw new Error("useNow must be called inside a NowProvider.");return r}const z0=2e3,$0=2500;function B0(r,l,s={}){const[u,c]=w.useState("connecting"),d=w.useRef(l);d.current=l;const p=w.useRef(s.matches);p.current=s.matches;const m=w.useRef(s.coalesceMs);m.current=s.coalesceMs;const y=r.join(","),x=w.useRef(0),k=w.useRef(null);return w.useEffect(()=>{if(r.length===0){c("closed");return}let R=null,T=!1,$=null,z=null,M=1e3,P=!1;const D=()=>{z!==null&&(clearTimeout(z),z=null)},G=ee=>{P||(P=!0,U0(ee))},Y=()=>{x.current=Date.now(),d.current()},q=()=>{const ee=m.current??$0,te=Date.now()-x.current;te>=ee?(k.current&&(clearTimeout(k.current),k.current=null),Y()):k.current===null&&(k.current=setTimeout(()=>{k.current=null,T||Y()},ee-te))},b=()=>{const ee=globalThis.EventSource;if(typeof ee!="function"){c("closed");return}const te=Cl();if(te===null){c("closed");return}const ne=new ee(Be().cityEventStreamUrl(te));R=ne,c("connecting"),z=setTimeout(()=>{T||R!==ne||ne.readyState===ee.CLOSED||c("open")},z0),R.onopen=()=>{T||(D(),c("open"),M=1e3)};const ye=oe=>{if(T)return;let _e=null;try{_e=JSON.parse(oe.data)}catch{c("degraded"),G("invalid JSON");return}if(!F0(_e)){c("degraded"),G("missing string event type");return}const Le=_e.type;if(typeof Le!="string"){c("degraded"),G("missing string event type");return}c("open");for(const Me of r)if(Le.startsWith(Me)){const Ie=_e;(p.current?.(Ie)??!0)&&q();break}};R.onmessage=ye,R.addEventListener("event",ye),R.onerror=()=>{T||(D(),c("closed"),R?.close(),R=null,$=setTimeout(()=>{M=Math.min(M*2,3e4),b()},M))}};return b(),()=>{T=!0,$&&clearTimeout($),D(),k.current&&(clearTimeout(k.current),k.current=null),R?.close()}},[y]),u}function U0(r){ar({component:"gc-events",operation:"parse event",message:`Malformed gc event payload: ${r}.`})}function F0(r){return typeof r=="object"&&r!==null&&!Array.isArray(r)}const V0=60*1e3;async function Nl(){const r=new Date().toISOString();try{const l=await fr.runSummary();return{source:"runs",status:"fresh",fetchedAt:r,staleAt:new Date(Date.parse(r)+V0).toISOString(),error:{kind:"none"},data:l}}catch(l){return{source:"runs",status:"error",error:Y0(l,"formula runs unavailable")}}}function W0(){return Nl()}function zw(){return Nl()}function H0(){return Nl()}function Q0(){return Nl()}function Y0(r,l){return r instanceof Error&&r.message.trim().length>0?r.message:l}const Tf=1e4,G0=[2e3,5e3,1e4];function q0(){const r=Cl(),l=w.useRef(null),s=w.useRef(!1),u=w.useCallback(async()=>{const b=await W0().catch(te=>({source:"runs",status:"error",error:te instanceof Error?te.message:"formula runs unavailable"}));if(b.status!=="error")return s.current=!1,b;const ee=l.current;return ee===null?b:(s.current=!0,{...ee,status:"stale"})},[]),c=w.useCallback(async()=>{const b=await H0().catch(te=>({source:"runs",status:"error",error:te instanceof Error?te.message:"formula runs unavailable"}));if(b.status!=="error")return b;const ee=l.current;return ee===null?b:(s.current=!0,{...ee,status:"stale"})},[]),{data:d,loading:p,error:m,refresh:y,cheapRefresh:x}=Vt(`runs:summary:${r??"no-city"}`,Q0,{refreshFetcher:u,sseRefreshFetcher:c});d!==void 0&&d.status!=="error"&&(l.current=d);const k=d??null,R=w.useRef(null);R.current=k?.status??null;const T=w.useRef(p);T.current=p;const $=w.useRef(0),z=w.useRef(null);w.useEffect(()=>{if(k===null||k.status==="error")return;const b=r??"no-city";z.current!==b&&(z.current=b,y().catch(()=>{z.current=null}))},[r,y,k]);const M=w.useRef(0);w.useEffect(()=>{if(k===null)return;if(!(k.status==="error"?!0:s.current||k.data.lanesPartial===!0&&k.data.lanes.length===0&&k.data.blockedLanes.length===0)){M.current=0;return}const ee=G0[M.current];if(ee===void 0)return;M.current+=1;const te=setTimeout(()=>{y()},ee);return()=>clearTimeout(te)},[k,y]);const P=w.useRef(!1),D=w.useRef(null),G=w.useCallback(()=>{D.current!==null&&(clearTimeout(D.current),D.current=null),$.current=Date.now(),x().catch(()=>{$.current=0})},[x]),Y=w.useCallback(()=>{if(R.current===null||R.current==="fixture")return;if(T.current){P.current=!0;return}Date.now()-$.current{if(p||!P.current)return;P.current=!1;const b=Math.max(0,Tf-(Date.now()-$.current));return D.current=setTimeout(G,b),()=>{D.current!==null&&(clearTimeout(D.current),D.current=null)}},[p,G]);const q=B0([Jh.bead],Y);return{source:d,loading:p,error:m,refresh:y,sseState:q}}const xd=w.createContext(null);function K0({children:r}){const l=q0();return N.jsx(xd.Provider,{value:l,children:r})}function X0(){const r=w.useContext(xd);if(r===null)throw new Error("useRunSummary must be used within a RunSummaryProvider");return r}const J0=w.lazy(()=>Yt(()=>import("./Agents-B1unG_7M.js"),__vite__mapDeps([7,8,1,9,10,2,11,3,12,6,13,14])).then(r=>({default:r.AgentsPage}))),Z0=w.lazy(()=>Yt(()=>import("./AgentDetail-CX9z-pqC.js"),__vite__mapDeps([15,16,6,17,12,3,10,2,8])).then(r=>({default:r.AgentDetailPage}))),b0=w.lazy(()=>Yt(()=>import("./AmbientHome-Tal9JCSE.js"),__vite__mapDeps([18,2])).then(r=>({default:r.AmbientHomePage}))),ew=w.lazy(()=>Yt(()=>import("./Beads-BlpKAjIK.js"),__vite__mapDeps([19,1,16,6,17,12,3,10,20,9,2,14])).then(r=>({default:r.BeadsPage}))),tw=w.lazy(()=>Yt(()=>import("./Mail-CA7kddiW.js"),__vite__mapDeps([21,9,1,20,13,10,2,17,3])).then(r=>({default:r.MailPage}))),nw=w.lazy(()=>Yt(()=>import("./FormulaRunDetail-C-9EnNQt.js"),__vite__mapDeps([22,2,16,6,17,12,3,10,23])).then(r=>({default:r.FormulaRunDetailPage}))),rw=w.lazy(()=>Yt(()=>import("./Runs-C0j-IP1W.js"),__vite__mapDeps([24,1,2,11,3,23])).then(r=>({default:r.RunsPage})));function iw(){const{data:r,error:l}=Vt("config",()=>fr.config()),s=r?.enabledModules??null,u=r?.defaultView??null,c=m0(r,l),d=a0(r),p=w.useMemo(()=>Sd(wd,s),[s]),m=w.useMemo(()=>A0(p,u),[p,u]),y=m.view?.element??null,x=m.redirectTo??null;return N.jsx(s0,{operator:d,children:N.jsx(x0,{children:N.jsx(D0,{children:N.jsx(d0,{readOnly:c,children:N.jsx(K0,{children:N.jsx(lw,{operator:d,children:N.jsxs(M0,{children:[l!==null&&N.jsx(sw,{message:l}),N.jsx(ow,{defaultRedirectTo:x,DefaultViewElement:y,enabledViews:p})]})})})})})})})}function lw({operator:r,children:l}){const{source:s}=X0(),u=Hg(r,s);return N.jsx(Iv,{contributors:u,children:l})}function ow({defaultRedirectTo:r,DefaultViewElement:l,enabledViews:s}){const{pathname:u}=Qt();return N.jsx(ad,{children:N.jsx(w.Suspense,{fallback:null,children:N.jsxs(xh,{children:[N.jsx(Ot,{path:"/",element:r!==null?N.jsx(Sh,{to:r,replace:!0}):l!==null?N.jsx(l,{}):N.jsx(b0,{})}),N.jsx(Ot,{path:"/agents",element:N.jsx(J0,{})}),N.jsx(Ot,{path:"/agents/:slug",element:N.jsx(Z0,{})}),N.jsx(Ot,{path:"/beads",element:N.jsx(ew,{})}),N.jsx(Ot,{path:"/runs",element:N.jsx(rw,{})}),N.jsx(Ot,{path:"/runs/:runId",element:N.jsx(nw,{})}),N.jsx(Ot,{path:"/mail",element:N.jsx(tw,{})}),s.map(c=>{const d=c.element;return N.jsx(Ot,{path:c.path,element:N.jsx(d,{})},c.id)}),N.jsx(Ot,{path:"*",element:N.jsx(aw,{})})]})})},u)}function sw({message:r}){return N.jsxs("section",{role:"alert",className:"mb-8 border border-warn/40 rounded-sm px-4 py-3 text-body text-fg-muted",children:[N.jsx("span",{className:"text-warn",children:"config unavailable:"})," ",r," · some controls may be disabled until it loads."]})}function aw(){return N.jsxs("section",{"aria-labelledby":"not-found-title",className:"space-y-3",children:[N.jsx("h1",{id:"not-found-title",className:"text-5xl font-semibold tracking-tight text-fg",children:"Page not found"}),N.jsx("p",{className:"text-title text-fg-muted",children:"No dashboard route matches this path."})]})}const uw={default:"border border-rule text-fg-muted hover:text-fg hover:bg-surface-tint",accent:"border border-accent text-accent hover:bg-accent hover:text-surface",quiet:"border border-transparent text-fg-muted hover:text-fg"},cw={sm:"px-2.5 py-1 text-label uppercase tracking-wider",md:"px-3.5 py-1.5 text-body"};function fw({tone:r="default",size:l="sm",className:s="",children:u,...c}){return N.jsx("button",{...c,className:`inline-flex items-center gap-1.5 rounded-sm transition-colors duration-150 ease-out-quart focus-mark disabled:opacity-40 disabled:cursor-not-allowed ${uw[r]} ${cw[l]} ${s}`,children:u})}const dw="https://docs.gascity.com/getting-started/quickstart",pw=/^\/city\/([^/]+)(?:\/|$)/;function mw(r){const l=pw.exec(r);if(l===null)return null;const s=l[1];if(s===void 0)return null;let u;try{u=decodeURIComponent(s)}catch{return null}return Wf.test(u)?{cityName:u,basename:`/city/${s}`}:null}function hw(){const r=w.useMemo(()=>mw(window.location.pathname),[]),[l,s]=w.useState({phase:"loading"}),[u,c]=w.useState(0),d=w.useCallback(()=>{s({phase:"loading"}),c(p=>p+1)},[]);return w.useEffect(()=>{let p=!1;return s({phase:"loading"}),Be().listCities().then(m=>{if(p)return;const y=m.items??[];if(r!==null){const k=y.some(R=>R.name===r.cityName);s(k?{phase:"mount"}:{phase:"unknown-city",cities:y});return}const x=y[0];if(x===void 0){s({phase:"empty"});return}window.location.replace(`/city/${encodeURIComponent(x.name)}/`)}).catch(m=>{if(!p){if(r!==null){s({phase:"mount"});return}s({phase:"error",message:m instanceof Error?m.message:"failed to load cities"})}}),()=>{p=!0}},[r,u]),r!==null&&l.phase==="mount"?(iv(r.cityName),N.jsx(Lh,{basename:r.basename,future:{v7_relativeSplatPath:!0,v7_startTransition:!0},children:N.jsx(iw,{})})):l.phase==="unknown-city"&&r!==null?N.jsx(vw,{cityName:r.cityName,cities:l.cities}):l.phase==="empty"?N.jsx(yw,{}):l.phase==="error"?N.jsx(gw,{message:l.message,onRetry:d}):N.jsx(Tl,{children:N.jsx("div",{className:"text-label uppercase tracking-wider text-fg-muted",children:"Resolving city…"})})}function Tl({children:r}){return N.jsx("div",{className:"min-h-screen bg-surface text-fg antialiased flex items-center justify-center px-6",children:N.jsx("div",{className:"max-w-prose w-full space-y-4",children:r})})}function vw({cityName:r,cities:l}){return N.jsx(Tl,{children:N.jsxs("section",{role:"alert",className:"space-y-4",children:[N.jsxs("h1",{className:"text-display font-semibold text-fg",children:["City “",r,"” is not registered on this supervisor."]}),l.length>0?N.jsxs("div",{className:"space-y-2",children:[N.jsx("p",{className:"text-body text-fg-muted",children:"Available cities:"}),N.jsx("ul",{className:"space-y-1",children:l.map(s=>N.jsxs("li",{children:[N.jsx("a",{href:`/city/${encodeURIComponent(s.name)}/`,className:"text-body text-accent hover:underline focus-mark",children:s.name}),s.running?null:N.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted ml-2",children:"· stopped"})]},s.name))})]}):N.jsx(kd,{})]})})}function yw(){return N.jsx(Tl,{children:N.jsxs("section",{className:"space-y-4",children:[N.jsx("h1",{className:"text-display font-semibold text-fg",children:"No cities are registered on this supervisor."}),N.jsx(kd,{})]})})}function kd(){return N.jsxs("div",{className:"space-y-3",children:[N.jsx("p",{className:"text-body text-fg-muted",children:"Create one from a terminal:"}),N.jsx("pre",{className:"text-body bg-surface-tint rounded-sm px-3 py-2 overflow-x-auto",children:N.jsx("code",{children:"gc init ~/my-city"})}),N.jsxs("p",{className:"text-body text-fg-muted",children:[N.jsx("code",{children:"gc init"})," bootstraps the city directory, registers it with the supervisor, and starts the orchestrator. Then refresh this page. See the"," ",N.jsx("a",{href:dw,target:"_blank",rel:"noreferrer",className:"text-accent hover:underline focus-mark",children:"getting-started guide"})," ","for the full walkthrough."]})]})}function gw({message:r,onRetry:l}){return N.jsx(Tl,{children:N.jsxs("section",{role:"alert",className:"space-y-4",children:[N.jsx("h1",{className:"text-display font-semibold text-fg",children:"Could not load cities."}),N.jsx("p",{className:"text-body text-fg-muted",children:r}),N.jsx(fw,{onClick:l,children:"Retry"})]})})}const Cd=document.getElementById("root");if(!Cd)throw new Error("missing #root");Lm.createRoot(Cd).render(N.jsx(Af.StrictMode,{children:N.jsx(l0,{children:N.jsx(ad,{children:N.jsx(hw,{})})})}));export{Cw as $,Xs as A,fw as B,ud as C,cd as D,zw as E,Cl as F,Jh as G,Be as H,Gt as I,Sw as J,y0 as K,jh as L,Ow as M,Ks as N,Fy as O,Nw as P,tv as Q,Iw as R,f0 as S,ev as T,Rw as U,_w as V,fr as W,Hf as X,jv as Y,xv as Z,As as _,Ov as a,gn as a0,Mw as a1,Aw as a2,jw as a3,S0 as a4,kf as a5,Gh as a6,X0 as a7,sg as a8,ag as a9,Ny as aa,Vt as b,zy as c,Ay as d,Vh as e,B0 as f,p0 as g,Ew as h,yd as i,N as j,xw as k,g0 as l,ig as m,Pw as n,Tw as o,sr as p,ww as q,w as r,Lw as s,Gs as t,Dw as u,k0 as v,hd as w,ar as x,kw as y,Rt as z}; diff --git a/internal/api/dashboardspa/dist/assets/index-DH0g_1Xl.css b/internal/api/dashboardspa/dist/assets/index-DH0g_1Xl.css new file mode 100644 index 0000000000..f4a2848f12 --- /dev/null +++ b/internal/api/dashboardspa/dist/assets/index-DH0g_1Xl.css @@ -0,0 +1 @@ +:root{--diff-background-color:initial;--diff-text-color:initial;--diff-font-family:Consolas,Courier,monospace;--diff-selection-background-color:#b3d7ff;--diff-selection-text-color:var(--diff-text-color);--diff-gutter-insert-background-color:#d6fedb;--diff-gutter-insert-text-color:var(--diff-text-color);--diff-gutter-delete-background-color:#fadde0;--diff-gutter-delete-text-color:var(--diff-text-color);--diff-gutter-selected-background-color:#fffce0;--diff-gutter-selected-text-color:var(--diff-text-color);--diff-code-insert-background-color:#eaffee;--diff-code-insert-text-color:var(--diff-text-color);--diff-code-delete-background-color:#fdeff0;--diff-code-delete-text-color:var(--diff-text-color);--diff-code-insert-edit-background-color:#c0dc91;--diff-code-insert-edit-text-color:var(--diff-text-color);--diff-code-delete-edit-background-color:#f39ea2;--diff-code-delete-edit-text-color:var(--diff-text-color);--diff-code-selected-background-color:#fffce0;--diff-code-selected-text-color:var(--diff-text-color);--diff-omit-gutter-line-color:#cb2a1d}.diff{background-color:var(--diff-background-color);border-collapse:collapse;color:var(--diff-text-color);table-layout:fixed;width:100%}.diff::-moz-selection{background-color:#b3d7ff;background-color:var(--diff-selection-background-color);color:var(--diff-text-color);color:var(--diff-selection-text-color)}.diff::selection{background-color:#b3d7ff;background-color:var(--diff-selection-background-color);color:var(--diff-text-color);color:var(--diff-selection-text-color)}.diff td{padding-bottom:0;padding-top:0;vertical-align:top}.diff-line{font-family:Consolas,Courier,monospace;font-family:var(--diff-font-family);line-height:1.5}.diff-gutter>a{color:inherit;display:block}.diff-gutter{cursor:pointer;padding:0 1ch;text-align:right;-webkit-user-select:none;-moz-user-select:none;user-select:none}.diff-gutter-insert{background-color:#d6fedb;background-color:var(--diff-gutter-insert-background-color);color:var(--diff-text-color);color:var(--diff-gutter-insert-text-color)}.diff-gutter-delete{background-color:#fadde0;background-color:var(--diff-gutter-delete-background-color);color:var(--diff-text-color);color:var(--diff-gutter-delete-text-color)}.diff-gutter-omit{cursor:default}.diff-gutter-selected{background-color:#fffce0;background-color:var(--diff-gutter-selected-background-color);color:var(--diff-text-color);color:var(--diff-gutter-selected-text-color)}.diff-code{word-wrap:break-word;padding:0 0 0 .5em;white-space:pre-wrap;word-break:break-all}.diff-code-edit{color:inherit}.diff-code-insert{background-color:#eaffee;background-color:var(--diff-code-insert-background-color);color:var(--diff-text-color);color:var(--diff-code-insert-text-color)}.diff-code-insert .diff-code-edit{background-color:#c0dc91;background-color:var(--diff-code-insert-edit-background-color);color:var(--diff-text-color);color:var(--diff-code-insert-edit-text-color)}.diff-code-delete{background-color:#fdeff0;background-color:var(--diff-code-delete-background-color);color:var(--diff-text-color);color:var(--diff-code-delete-text-color)}.diff-code-delete .diff-code-edit{background-color:#f39ea2;background-color:var(--diff-code-delete-edit-background-color);color:var(--diff-text-color);color:var(--diff-code-delete-edit-text-color)}.diff-code-selected{background-color:#fffce0;background-color:var(--diff-code-selected-background-color);color:var(--diff-text-color);color:var(--diff-code-selected-text-color)}.diff-widget-content{vertical-align:top}.diff-gutter-col{width:7ch}.diff-gutter-omit{height:0}.diff-gutter-omit:before{background-color:#cb2a1d;background-color:var(--diff-omit-gutter-line-color);content:" ";display:block;height:100%;margin-left:4.6ch;overflow:hidden;white-space:pre;width:2px}.diff-decoration{line-height:1.5;-webkit-user-select:none;-moz-user-select:none;user-select:none}.diff-decoration-content{font-family:Consolas,Courier,monospace;font-family:var(--diff-font-family);padding:0}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2) format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2) format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2) format("woff2-variations");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/inter-greek-wght-normal-CkhJZR-_.woff2) format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2) format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2) format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/inter-latin-wght-normal-Dx4kXJAl.woff2) format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter Variable,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}*,:before,:after{border-color:oklch(var(--rule))}:root{--surface: 96% .012 75;--surface-tint: 92% .014 75;--fg: 18% .012 75;--fg-muted: 42% .014 75;--fg-faint: 52% .014 75;--rule: 80% .012 75;--accent: 40% .13 25;--ok: 50% .085 150;--warn: 60% .14 60;color-scheme:light;accent-color:oklch(var(--accent))}:root[data-theme=dark]{--surface: 16% .008 75;--surface-tint: 22% .012 75;--fg: 92% .006 75;--fg-muted: 68% .014 75;--fg-faint: 54% .012 75;--rule: 28% .01 75;--accent: 72% .12 25;--ok: 70% .085 150;--warn: 76% .14 60;color-scheme:dark}@media(prefers-color-scheme:dark){:root:not([data-theme=light]):not([data-theme=dark]){--surface: 16% .008 75;--surface-tint: 22% .012 75;--fg: 92% .006 75;--fg-muted: 68% .014 75;--fg-faint: 54% .012 75;--rule: 28% .01 75;--accent: 72% .12 25;--ok: 70% .085 150;--warn: 76% .14 60;color-scheme:dark}}body{background:oklch(var(--surface));color:oklch(var(--fg));font-family:Inter Variable,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,system-ui,sans-serif;font-feature-settings:"cv02","cv03","cv04","cv11","ss01","kern";font-optical-sizing:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}h1,h2,h3{text-wrap:balance}p{text-wrap:pretty}::-moz-selection{background:oklch(var(--accent) / .2);color:oklch(var(--fg))}::selection{background:oklch(var(--accent) / .2);color:oklch(var(--fg))}.container{width:100%}@media(min-width:640px){.container{max-width:640px}}@media(min-width:768px){.container{max-width:768px}}@media(min-width:1024px){.container{max-width:1024px}}@media(min-width:1280px){.container{max-width:1280px}}@media(min-width:1536px){.container{max-width:1536px}}.tnum{font-variant-numeric:tabular-nums}.formula-run-diff-view{--diff-background-color: transparent;--diff-text-color: oklch(var(--fg));--diff-font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;--diff-selection-background-color: oklch(var(--surface-tint));--diff-selection-text-color: oklch(var(--fg));--diff-gutter-insert-background-color: oklch(var(--ok) / .1);--diff-gutter-insert-text-color: oklch(var(--fg-muted));--diff-gutter-delete-background-color: oklch(var(--warn) / .1);--diff-gutter-delete-text-color: oklch(var(--fg-muted));--diff-code-insert-background-color: oklch(var(--ok) / .1);--diff-code-insert-text-color: oklch(var(--fg));--diff-code-delete-background-color: oklch(var(--warn) / .1);--diff-code-delete-text-color: oklch(var(--fg-muted));--diff-code-insert-edit-background-color: oklch(var(--ok) / .18);--diff-code-delete-edit-background-color: oklch(var(--warn) / .18);--diff-code-selected-background-color: oklch(var(--surface-tint));--diff-omit-gutter-line-color: oklch(var(--rule))}.formula-run-diff-view .diff{font-size:.8125rem}.formula-run-diff-view .diff-code{word-break:normal;overflow-wrap:anywhere}.formula-run-diff-view .diff-gutter-sign{display:block;color:oklch(var(--fg-faint))}.focus-mark:focus-visible{outline:2px solid oklch(var(--accent));outline-offset:1px;border-radius:2px}.formula-run-node-shape-root{border-width:3px;border-style:double;border-radius:3px}.formula-run-node-shape-step{border-width:1px;border-style:solid;border-radius:3px}.formula-run-node-shape-retry{border-width:2px;border-style:double;border-radius:9999px;outline:1px solid oklch(var(--rule));outline-offset:3px}.formula-run-node-shape-check-loop{border-width:2px;border-style:double;border-radius:9999px 4px 4px 9999px}.formula-run-node-shape-scope{border-width:1px;border-style:dashed;border-radius:3px 10px 10px 3px}.formula-run-node-shape-condition{border-width:1px;border-style:dashed;border-radius:18px 4px}.formula-run-node-shape-fanout{border-width:1px;border-style:dashed;border-radius:6px;background-image:repeating-linear-gradient(90deg,transparent 0,transparent .75rem,oklch(var(--rule) / .28) .75rem,oklch(var(--rule) / .28) .8125rem)}.formula-run-node-shape-expansion{border-width:1px;border-style:dashed;border-radius:6px;outline:1px dashed oklch(var(--rule));outline-offset:3px}.formula-run-node-shape-control{border-width:1px;border-style:dotted;border-radius:4px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-\[5\%\]{inset:5%}.bottom-\[-0\.75rem\]{bottom:-.75rem}.left-2{left:.5rem}.left-\[0\.3125rem\]{left:.3125rem}.top-10{top:2.5rem}.top-7{top:1.75rem}.z-50{z-index:50}.z-\[60\]{z-index:60}.z-\[61\]{z-index:61}.-mx-2{margin-left:-.5rem;margin-right:-.5rem}.mx-1\.5{margin-left:.375rem;margin-right:.375rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.-ml-4{margin-left:-1rem}.-mr-2{margin-right:-.5rem}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-auto{margin-left:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-16{height:4rem}.h-2{height:.5rem}.h-3\.5{height:.875rem}.h-96{height:24rem}.max-h-\[28rem\]{max-height:28rem}.max-h-\[90vh\]{max-height:90vh}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-2{width:.5rem}.w-20{width:5rem}.w-24{width:6rem}.w-28{width:7rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-48{width:12rem}.w-8{width:2rem}.w-80{width:20rem}.w-full{width:100%}.min-w-0{min-width:0px}.min-w-36{min-width:9rem}.min-w-40{min-width:10rem}.min-w-44{min-width:11rem}.min-w-56{min-width:14rem}.min-w-\[18rem\]{min-width:18rem}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-\[70ch\]{max-width:70ch}.max-w-dashboard{max-width:1280px}.max-w-prose{max-width:70ch}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.translate-y-\[1px\]{--tw-translate-y: 1px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-\[2px\]{--tw-translate-y: 2px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-100{--tw-scale-x: 1;--tw-scale-y: 1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-95{--tw-scale-x: .95;--tw-scale-y: .95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize-y{resize:vertical}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[1fr_max-content\]{grid-template-columns:1fr max-content}.grid-cols-\[1fr_max-content_max-content\]{grid-template-columns:1fr max-content max-content}.grid-cols-\[7rem_minmax\(6\.5rem\,1fr\)\]{grid-template-columns:7rem minmax(6.5rem,1fr)}.grid-cols-\[max-content_1fr\]{grid-template-columns:max-content 1fr}.grid-cols-\[max-content_minmax\(0\,1fr\)\]{grid-template-columns:max-content minmax(0,1fr)}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-10{gap:2.5rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.gap-x-3{-moz-column-gap:.75rem;column-gap:.75rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-x-5{-moz-column-gap:1.25rem;column-gap:1.25rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-x-8{-moz-column-gap:2rem;column-gap:2rem}.gap-y-1{row-gap:.25rem}.gap-y-2{row-gap:.5rem}.gap-y-3{row-gap:.75rem}.gap-y-4{row-gap:1rem}.gap-y-5{row-gap:1.25rem}.gap-y-8{row-gap:2rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-12>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(3rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(3rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(2rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-rule>:not([hidden])~:not([hidden]){--tw-divide-opacity: 1;border-color:oklch(var(--rule) / var(--tw-divide-opacity, 1))}.overflow-auto{overflow:auto}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-all{word-break:break-all}.rounded{border-radius:4px}.rounded-full{border-radius:9999px}.rounded-md{border-radius:6px}.rounded-sm{border-radius:2px}.border{border-width:1px}.border-0{border-width:0px}.border-y{border-top-width:1px;border-bottom-width:1px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-t{border-top-width:1px}.border-accent{--tw-border-opacity: 1;border-color:oklch(var(--accent) / var(--tw-border-opacity, 1))}.border-fg{--tw-border-opacity: 1;border-color:oklch(var(--fg) / var(--tw-border-opacity, 1))}.border-rule{--tw-border-opacity: 1;border-color:oklch(var(--rule) / var(--tw-border-opacity, 1))}.border-transparent{border-color:transparent}.border-warn{--tw-border-opacity: 1;border-color:oklch(var(--warn) / var(--tw-border-opacity, 1))}.border-warn\/40{border-color:oklch(var(--warn) / .4)}.bg-accent\/10{background-color:oklch(var(--accent) / .1)}.bg-accent\/5{background-color:oklch(var(--accent) / .05)}.bg-fg-faint{--tw-bg-opacity: 1;background-color:oklch(var(--fg-faint) / var(--tw-bg-opacity, 1))}.bg-fg\/30{background-color:oklch(var(--fg) / .3)}.bg-surface{--tw-bg-opacity: 1;background-color:oklch(var(--surface) / var(--tw-bg-opacity, 1))}.bg-surface-tint{--tw-bg-opacity: 1;background-color:oklch(var(--surface-tint) / var(--tw-bg-opacity, 1))}.bg-transparent{background-color:transparent}.bg-warn\/10{background-color:oklch(var(--warn) / .1)}.bg-warn\/5{background-color:oklch(var(--warn) / .05)}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.px-0{padding-left:0;padding-right:0}.px-0\.5{padding-left:.125rem;padding-right:.125rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-10{padding-top:2.5rem;padding-bottom:2.5rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.pb-1{padding-bottom:.25rem}.pb-2{padding-bottom:.5rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-6{padding-bottom:1.5rem}.pl-4{padding-left:1rem}.pl-6{padding-left:1.5rem}.pr-2{padding-right:.5rem}.pr-6{padding-right:1.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-baseline{vertical-align:baseline}.align-super{vertical-align:super}.font-sans{font-family:Inter Variable,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,system-ui,sans-serif}.text-5xl{font-size:3rem;line-height:1}.text-\[0\.65rem\]{font-size:.65rem}.text-\[0\.85em\]{font-size:.85em}.text-body{font-size:.9375rem;line-height:1.55}.text-display{font-size:2.5rem;line-height:1.05;letter-spacing:-.02em}.text-headline{font-size:1.5rem;line-height:1.15;letter-spacing:-.01em}.text-label{font-size:.75rem;line-height:1.2;letter-spacing:.04em}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-title{font-size:1rem;line-height:1.35}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.normal-case{text-transform:none}.italic{font-style:italic}.not-italic{font-style:normal}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-\[1\.05\]{line-height:1.05}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-normal{letter-spacing:0}.tracking-tight{letter-spacing:-.01em}.tracking-tighter{letter-spacing:-.02em}.tracking-wider{letter-spacing:.04em}.text-accent{--tw-text-opacity: 1;color:oklch(var(--accent) / var(--tw-text-opacity, 1))}.text-fg{--tw-text-opacity: 1;color:oklch(var(--fg) / var(--tw-text-opacity, 1))}.text-fg-faint{--tw-text-opacity: 1;color:oklch(var(--fg-faint) / var(--tw-text-opacity, 1))}.text-fg-muted{--tw-text-opacity: 1;color:oklch(var(--fg-muted) / var(--tw-text-opacity, 1))}.text-ok{--tw-text-opacity: 1;color:oklch(var(--ok) / var(--tw-text-opacity, 1))}.text-warn{--tw-text-opacity: 1;color:oklch(var(--warn) / var(--tw-text-opacity, 1))}.underline{text-decoration-line:underline}.decoration-fg{text-decoration-color:oklch(var(--fg) / 1)}.decoration-dotted{text-decoration-style:dotted}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.accent-fg{accent-color:oklch(var(--fg) / 1)}.opacity-0{opacity:0}.opacity-100{opacity:1}.shadow{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.ring-2{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-accent\/45{--tw-ring-color: oklch(var(--accent) / .45)}.ring-offset-2{--tw-ring-offset-width: 2px}.ring-offset-surface{--tw-ring-offset-color: oklch(var(--surface) / 1)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-\[opacity\,transform\]{transition-property:opacity,transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-150{transition-duration:.15s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.ease-out-quart{transition-timing-function:cubic-bezier(.25,1,.5,1)}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}.placeholder\:text-fg-faint::-moz-placeholder{--tw-text-opacity: 1;color:oklch(var(--fg-faint) / var(--tw-text-opacity, 1))}.placeholder\:text-fg-faint::placeholder{--tw-text-opacity: 1;color:oklch(var(--fg-faint) / var(--tw-text-opacity, 1))}.last\:border-0:last-child{border-width:0px}.last\:border-b-0:last-child{border-bottom-width:0px}.hover\:border-fg-faint:hover{--tw-border-opacity: 1;border-color:oklch(var(--fg-faint) / var(--tw-border-opacity, 1))}.hover\:bg-accent:hover{--tw-bg-opacity: 1;background-color:oklch(var(--accent) / var(--tw-bg-opacity, 1))}.hover\:bg-accent\/15:hover{background-color:oklch(var(--accent) / .15)}.hover\:bg-surface-tint:hover{--tw-bg-opacity: 1;background-color:oklch(var(--surface-tint) / var(--tw-bg-opacity, 1))}.hover\:bg-surface-tint\/60:hover{background-color:oklch(var(--surface-tint) / .6)}.hover\:bg-warn\/15:hover{background-color:oklch(var(--warn) / .15)}.hover\:text-accent:hover{--tw-text-opacity: 1;color:oklch(var(--accent) / var(--tw-text-opacity, 1))}.hover\:text-fg:hover{--tw-text-opacity: 1;color:oklch(var(--fg) / var(--tw-text-opacity, 1))}.hover\:text-fg-muted:hover{--tw-text-opacity: 1;color:oklch(var(--fg-muted) / var(--tw-text-opacity, 1))}.hover\:text-surface:hover{--tw-text-opacity: 1;color:oklch(var(--surface) / var(--tw-text-opacity, 1))}.hover\:underline:hover{text-decoration-line:underline}.focus\:border-accent:focus{--tw-border-opacity: 1;border-color:oklch(var(--accent) / var(--tw-border-opacity, 1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-0:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-1:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-accent\/40:focus{--tw-ring-color: oklch(var(--accent) / .4)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:text-accent{--tw-text-opacity: 1;color:oklch(var(--accent) / var(--tw-text-opacity, 1))}.group:hover .group-hover\:text-fg{--tw-text-opacity: 1;color:oklch(var(--fg) / var(--tw-text-opacity, 1))}.group:hover .group-hover\:text-fg-muted{--tw-text-opacity: 1;color:oklch(var(--fg-muted) / var(--tw-text-opacity, 1))}@media(prefers-reduced-motion:reduce){.motion-reduce\:transition-none{transition-property:none}}@media(min-width:640px){.sm\:w-44{width:11rem}.sm\:w-64{width:16rem}.sm\:w-\[34rem\]{width:34rem}.sm\:shrink-0{flex-shrink:0}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-\[7rem_6\.5rem_10rem_7rem\]{grid-template-columns:7rem 6.5rem 10rem 7rem}.sm\:flex-row{flex-direction:row}.sm\:items-start{align-items:flex-start}.sm\:items-center{align-items:center}.sm\:border-b-0{border-bottom-width:0px}.sm\:border-r{border-right-width:1px}.sm\:p-6{padding:1.5rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:pb-0{padding-bottom:0}.sm\:pr-6{padding-right:1.5rem}}@media(min-width:768px){.md\:grid-cols-\[minmax\(0\,1fr\)_auto\]{grid-template-columns:minmax(0,1fr) auto}.md\:items-start{align-items:flex-start}.md\:items-end{align-items:flex-end}.md\:justify-end{justify-content:flex-end}}@media(min-width:1024px){.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[minmax\(0\,0\.95fr\)_minmax\(22rem\,1\.05fr\)\]{grid-template-columns:minmax(0,.95fr) minmax(22rem,1.05fr)}.lg\:gap-x-7{-moz-column-gap:1.75rem;column-gap:1.75rem}.lg\:gap-x-8{-moz-column-gap:2rem;column-gap:2rem}.lg\:px-8{padding-left:2rem;padding-right:2rem}}@media(min-width:1280px){.xl\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}} diff --git a/internal/api/dashboardspa/dist/assets/index-Gx0U3WJJ.css b/internal/api/dashboardspa/dist/assets/index-Gx0U3WJJ.css deleted file mode 100644 index 2a4e9a7839..0000000000 --- a/internal/api/dashboardspa/dist/assets/index-Gx0U3WJJ.css +++ /dev/null @@ -1 +0,0 @@ -:root{--diff-background-color:initial;--diff-text-color:initial;--diff-font-family:Consolas,Courier,monospace;--diff-selection-background-color:#b3d7ff;--diff-selection-text-color:var(--diff-text-color);--diff-gutter-insert-background-color:#d6fedb;--diff-gutter-insert-text-color:var(--diff-text-color);--diff-gutter-delete-background-color:#fadde0;--diff-gutter-delete-text-color:var(--diff-text-color);--diff-gutter-selected-background-color:#fffce0;--diff-gutter-selected-text-color:var(--diff-text-color);--diff-code-insert-background-color:#eaffee;--diff-code-insert-text-color:var(--diff-text-color);--diff-code-delete-background-color:#fdeff0;--diff-code-delete-text-color:var(--diff-text-color);--diff-code-insert-edit-background-color:#c0dc91;--diff-code-insert-edit-text-color:var(--diff-text-color);--diff-code-delete-edit-background-color:#f39ea2;--diff-code-delete-edit-text-color:var(--diff-text-color);--diff-code-selected-background-color:#fffce0;--diff-code-selected-text-color:var(--diff-text-color);--diff-omit-gutter-line-color:#cb2a1d}.diff{background-color:var(--diff-background-color);border-collapse:collapse;color:var(--diff-text-color);table-layout:fixed;width:100%}.diff::-moz-selection{background-color:#b3d7ff;background-color:var(--diff-selection-background-color);color:var(--diff-text-color);color:var(--diff-selection-text-color)}.diff::selection{background-color:#b3d7ff;background-color:var(--diff-selection-background-color);color:var(--diff-text-color);color:var(--diff-selection-text-color)}.diff td{padding-bottom:0;padding-top:0;vertical-align:top}.diff-line{font-family:Consolas,Courier,monospace;font-family:var(--diff-font-family);line-height:1.5}.diff-gutter>a{color:inherit;display:block}.diff-gutter{cursor:pointer;padding:0 1ch;text-align:right;-webkit-user-select:none;-moz-user-select:none;user-select:none}.diff-gutter-insert{background-color:#d6fedb;background-color:var(--diff-gutter-insert-background-color);color:var(--diff-text-color);color:var(--diff-gutter-insert-text-color)}.diff-gutter-delete{background-color:#fadde0;background-color:var(--diff-gutter-delete-background-color);color:var(--diff-text-color);color:var(--diff-gutter-delete-text-color)}.diff-gutter-omit{cursor:default}.diff-gutter-selected{background-color:#fffce0;background-color:var(--diff-gutter-selected-background-color);color:var(--diff-text-color);color:var(--diff-gutter-selected-text-color)}.diff-code{word-wrap:break-word;padding:0 0 0 .5em;white-space:pre-wrap;word-break:break-all}.diff-code-edit{color:inherit}.diff-code-insert{background-color:#eaffee;background-color:var(--diff-code-insert-background-color);color:var(--diff-text-color);color:var(--diff-code-insert-text-color)}.diff-code-insert .diff-code-edit{background-color:#c0dc91;background-color:var(--diff-code-insert-edit-background-color);color:var(--diff-text-color);color:var(--diff-code-insert-edit-text-color)}.diff-code-delete{background-color:#fdeff0;background-color:var(--diff-code-delete-background-color);color:var(--diff-text-color);color:var(--diff-code-delete-text-color)}.diff-code-delete .diff-code-edit{background-color:#f39ea2;background-color:var(--diff-code-delete-edit-background-color);color:var(--diff-text-color);color:var(--diff-code-delete-edit-text-color)}.diff-code-selected{background-color:#fffce0;background-color:var(--diff-code-selected-background-color);color:var(--diff-text-color);color:var(--diff-code-selected-text-color)}.diff-widget-content{vertical-align:top}.diff-gutter-col{width:7ch}.diff-gutter-omit{height:0}.diff-gutter-omit:before{background-color:#cb2a1d;background-color:var(--diff-omit-gutter-line-color);content:" ";display:block;height:100%;margin-left:4.6ch;overflow:hidden;white-space:pre;width:2px}.diff-decoration{line-height:1.5;-webkit-user-select:none;-moz-user-select:none;user-select:none}.diff-decoration-content{font-family:Consolas,Courier,monospace;font-family:var(--diff-font-family);padding:0}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2) format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2) format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2) format("woff2-variations");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/inter-greek-wght-normal-CkhJZR-_.woff2) format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2) format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2) format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(/assets/inter-latin-wght-normal-Dx4kXJAl.woff2) format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter Variable,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}*,:before,:after{border-color:oklch(var(--rule))}:root{--surface: 96% .012 75;--surface-tint: 92% .014 75;--fg: 18% .012 75;--fg-muted: 42% .014 75;--fg-faint: 52% .014 75;--rule: 80% .012 75;--accent: 40% .13 25;--ok: 50% .085 150;--warn: 60% .14 60;color-scheme:light;accent-color:oklch(var(--accent))}:root[data-theme=dark]{--surface: 16% .008 75;--surface-tint: 22% .012 75;--fg: 92% .006 75;--fg-muted: 68% .014 75;--fg-faint: 54% .012 75;--rule: 28% .01 75;--accent: 72% .12 25;--ok: 70% .085 150;--warn: 76% .14 60;color-scheme:dark}@media(prefers-color-scheme:dark){:root:not([data-theme=light]):not([data-theme=dark]){--surface: 16% .008 75;--surface-tint: 22% .012 75;--fg: 92% .006 75;--fg-muted: 68% .014 75;--fg-faint: 54% .012 75;--rule: 28% .01 75;--accent: 72% .12 25;--ok: 70% .085 150;--warn: 76% .14 60;color-scheme:dark}}body{background:oklch(var(--surface));color:oklch(var(--fg));font-family:Inter Variable,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,system-ui,sans-serif;font-feature-settings:"cv02","cv03","cv04","cv11","ss01","kern";font-optical-sizing:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}h1,h2,h3{text-wrap:balance}p{text-wrap:pretty}::-moz-selection{background:oklch(var(--accent) / .2);color:oklch(var(--fg))}::selection{background:oklch(var(--accent) / .2);color:oklch(var(--fg))}.container{width:100%}@media(min-width:640px){.container{max-width:640px}}@media(min-width:768px){.container{max-width:768px}}@media(min-width:1024px){.container{max-width:1024px}}@media(min-width:1280px){.container{max-width:1280px}}@media(min-width:1536px){.container{max-width:1536px}}.tnum{font-variant-numeric:tabular-nums}.formula-run-diff-view{--diff-background-color: transparent;--diff-text-color: oklch(var(--fg));--diff-font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;--diff-selection-background-color: oklch(var(--surface-tint));--diff-selection-text-color: oklch(var(--fg));--diff-gutter-insert-background-color: oklch(var(--ok) / .1);--diff-gutter-insert-text-color: oklch(var(--fg-muted));--diff-gutter-delete-background-color: oklch(var(--warn) / .1);--diff-gutter-delete-text-color: oklch(var(--fg-muted));--diff-code-insert-background-color: oklch(var(--ok) / .1);--diff-code-insert-text-color: oklch(var(--fg));--diff-code-delete-background-color: oklch(var(--warn) / .1);--diff-code-delete-text-color: oklch(var(--fg-muted));--diff-code-insert-edit-background-color: oklch(var(--ok) / .18);--diff-code-delete-edit-background-color: oklch(var(--warn) / .18);--diff-code-selected-background-color: oklch(var(--surface-tint));--diff-omit-gutter-line-color: oklch(var(--rule))}.formula-run-diff-view .diff{font-size:.8125rem}.formula-run-diff-view .diff-code{word-break:normal;overflow-wrap:anywhere}.formula-run-diff-view .diff-gutter-sign{display:block;color:oklch(var(--fg-faint))}.focus-mark:focus-visible{outline:2px solid oklch(var(--accent));outline-offset:1px;border-radius:2px}.formula-run-node-shape-root{border-width:3px;border-style:double;border-radius:3px}.formula-run-node-shape-step{border-width:1px;border-style:solid;border-radius:3px}.formula-run-node-shape-retry{border-width:2px;border-style:double;border-radius:9999px;outline:1px solid oklch(var(--rule));outline-offset:3px}.formula-run-node-shape-check-loop{border-width:2px;border-style:double;border-radius:9999px 4px 4px 9999px}.formula-run-node-shape-scope{border-width:1px;border-style:dashed;border-radius:3px 10px 10px 3px}.formula-run-node-shape-condition{border-width:1px;border-style:dashed;border-radius:18px 4px}.formula-run-node-shape-fanout{border-width:1px;border-style:dashed;border-radius:6px;background-image:repeating-linear-gradient(90deg,transparent 0,transparent .75rem,oklch(var(--rule) / .28) .75rem,oklch(var(--rule) / .28) .8125rem)}.formula-run-node-shape-expansion{border-width:1px;border-style:dashed;border-radius:6px;outline:1px dashed oklch(var(--rule));outline-offset:3px}.formula-run-node-shape-control{border-width:1px;border-style:dotted;border-radius:4px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-\[5\%\]{inset:5%}.bottom-\[-0\.75rem\]{bottom:-.75rem}.left-2{left:.5rem}.left-\[0\.3125rem\]{left:.3125rem}.top-10{top:2.5rem}.top-7{top:1.75rem}.z-50{z-index:50}.z-\[60\]{z-index:60}.z-\[61\]{z-index:61}.-mx-2{margin-left:-.5rem;margin-right:-.5rem}.mx-1\.5{margin-left:.375rem;margin-right:.375rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.-ml-4{margin-left:-1rem}.-mr-2{margin-right:-.5rem}.mb-1{margin-bottom:.25rem}.mb-10{margin-bottom:2.5rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-auto{margin-left:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-16{height:4rem}.h-2{height:.5rem}.h-3\.5{height:.875rem}.h-96{height:24rem}.max-h-\[28rem\]{max-height:28rem}.max-h-\[60vh\]{max-height:60vh}.max-h-\[90vh\]{max-height:90vh}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-2{width:.5rem}.w-20{width:5rem}.w-24{width:6rem}.w-28{width:7rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-48{width:12rem}.w-8{width:2rem}.w-80{width:20rem}.w-full{width:100%}.min-w-0{min-width:0px}.min-w-36{min-width:9rem}.min-w-40{min-width:10rem}.min-w-44{min-width:11rem}.min-w-56{min-width:14rem}.min-w-\[18rem\]{min-width:18rem}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-\[70ch\]{max-width:70ch}.max-w-dashboard{max-width:1280px}.max-w-prose{max-width:70ch}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.translate-y-\[1px\]{--tw-translate-y: 1px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-\[2px\]{--tw-translate-y: 2px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-100{--tw-scale-x: 1;--tw-scale-y: 1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-95{--tw-scale-x: .95;--tw-scale-y: .95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize-y{resize:vertical}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[1fr_max-content\]{grid-template-columns:1fr max-content}.grid-cols-\[1fr_max-content_max-content\]{grid-template-columns:1fr max-content max-content}.grid-cols-\[7rem_minmax\(6\.5rem\,1fr\)\]{grid-template-columns:7rem minmax(6.5rem,1fr)}.grid-cols-\[max-content_1fr\]{grid-template-columns:max-content 1fr}.grid-cols-\[max-content_minmax\(0\,1fr\)\]{grid-template-columns:max-content minmax(0,1fr)}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-10{gap:2.5rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.gap-x-3{-moz-column-gap:.75rem;column-gap:.75rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-x-5{-moz-column-gap:1.25rem;column-gap:1.25rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-x-8{-moz-column-gap:2rem;column-gap:2rem}.gap-y-1{row-gap:.25rem}.gap-y-2{row-gap:.5rem}.gap-y-3{row-gap:.75rem}.gap-y-4{row-gap:1rem}.gap-y-5{row-gap:1.25rem}.gap-y-8{row-gap:2rem}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-12>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(3rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(3rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(2rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-rule>:not([hidden])~:not([hidden]){--tw-divide-opacity: 1;border-color:oklch(var(--rule) / var(--tw-divide-opacity, 1))}.overflow-auto{overflow:auto}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-all{word-break:break-all}.rounded{border-radius:4px}.rounded-full{border-radius:9999px}.rounded-md{border-radius:6px}.rounded-sm{border-radius:2px}.border{border-width:1px}.border-0{border-width:0px}.border-y{border-top-width:1px;border-bottom-width:1px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-t{border-top-width:1px}.border-accent{--tw-border-opacity: 1;border-color:oklch(var(--accent) / var(--tw-border-opacity, 1))}.border-fg{--tw-border-opacity: 1;border-color:oklch(var(--fg) / var(--tw-border-opacity, 1))}.border-rule{--tw-border-opacity: 1;border-color:oklch(var(--rule) / var(--tw-border-opacity, 1))}.border-transparent{border-color:transparent}.border-warn{--tw-border-opacity: 1;border-color:oklch(var(--warn) / var(--tw-border-opacity, 1))}.border-warn\/40{border-color:oklch(var(--warn) / .4)}.bg-accent\/10{background-color:oklch(var(--accent) / .1)}.bg-accent\/5{background-color:oklch(var(--accent) / .05)}.bg-fg-faint{--tw-bg-opacity: 1;background-color:oklch(var(--fg-faint) / var(--tw-bg-opacity, 1))}.bg-fg\/30{background-color:oklch(var(--fg) / .3)}.bg-surface{--tw-bg-opacity: 1;background-color:oklch(var(--surface) / var(--tw-bg-opacity, 1))}.bg-surface-tint{--tw-bg-opacity: 1;background-color:oklch(var(--surface-tint) / var(--tw-bg-opacity, 1))}.bg-transparent{background-color:transparent}.bg-warn\/10{background-color:oklch(var(--warn) / .1)}.bg-warn\/5{background-color:oklch(var(--warn) / .05)}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.px-0{padding-left:0;padding-right:0}.px-0\.5{padding-left:.125rem;padding-right:.125rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-10{padding-top:2.5rem;padding-bottom:2.5rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.pb-1{padding-bottom:.25rem}.pb-2{padding-bottom:.5rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-6{padding-bottom:1.5rem}.pl-4{padding-left:1rem}.pl-6{padding-left:1.5rem}.pr-2{padding-right:.5rem}.pr-6{padding-right:1.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-baseline{vertical-align:baseline}.align-super{vertical-align:super}.font-sans{font-family:Inter Variable,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,system-ui,sans-serif}.text-5xl{font-size:3rem;line-height:1}.text-\[0\.65rem\]{font-size:.65rem}.text-\[0\.85em\]{font-size:.85em}.text-body{font-size:.9375rem;line-height:1.55}.text-display{font-size:2.5rem;line-height:1.05;letter-spacing:-.02em}.text-headline{font-size:1.5rem;line-height:1.15;letter-spacing:-.01em}.text-label{font-size:.75rem;line-height:1.2;letter-spacing:.04em}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-title{font-size:1rem;line-height:1.35}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.capitalize{text-transform:capitalize}.normal-case{text-transform:none}.italic{font-style:italic}.not-italic{font-style:normal}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-\[1\.05\]{line-height:1.05}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-normal{letter-spacing:0}.tracking-tight{letter-spacing:-.01em}.tracking-tighter{letter-spacing:-.02em}.tracking-wider{letter-spacing:.04em}.text-accent{--tw-text-opacity: 1;color:oklch(var(--accent) / var(--tw-text-opacity, 1))}.text-fg{--tw-text-opacity: 1;color:oklch(var(--fg) / var(--tw-text-opacity, 1))}.text-fg-faint{--tw-text-opacity: 1;color:oklch(var(--fg-faint) / var(--tw-text-opacity, 1))}.text-fg-muted{--tw-text-opacity: 1;color:oklch(var(--fg-muted) / var(--tw-text-opacity, 1))}.text-ok{--tw-text-opacity: 1;color:oklch(var(--ok) / var(--tw-text-opacity, 1))}.text-warn{--tw-text-opacity: 1;color:oklch(var(--warn) / var(--tw-text-opacity, 1))}.underline{text-decoration-line:underline}.decoration-fg{text-decoration-color:oklch(var(--fg) / 1)}.decoration-dotted{text-decoration-style:dotted}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.accent-fg{accent-color:oklch(var(--fg) / 1)}.opacity-0{opacity:0}.opacity-100{opacity:1}.shadow{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.ring-2{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-accent\/45{--tw-ring-color: oklch(var(--accent) / .45)}.ring-offset-2{--tw-ring-offset-width: 2px}.ring-offset-surface{--tw-ring-offset-color: oklch(var(--surface) / 1)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-\[opacity\,transform\]{transition-property:opacity,transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-150{transition-duration:.15s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.ease-out-quart{transition-timing-function:cubic-bezier(.25,1,.5,1)}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}.placeholder\:text-fg-faint::-moz-placeholder{--tw-text-opacity: 1;color:oklch(var(--fg-faint) / var(--tw-text-opacity, 1))}.placeholder\:text-fg-faint::placeholder{--tw-text-opacity: 1;color:oklch(var(--fg-faint) / var(--tw-text-opacity, 1))}.last\:border-0:last-child{border-width:0px}.last\:border-b-0:last-child{border-bottom-width:0px}.hover\:border-fg-faint:hover{--tw-border-opacity: 1;border-color:oklch(var(--fg-faint) / var(--tw-border-opacity, 1))}.hover\:bg-accent:hover{--tw-bg-opacity: 1;background-color:oklch(var(--accent) / var(--tw-bg-opacity, 1))}.hover\:bg-accent\/15:hover{background-color:oklch(var(--accent) / .15)}.hover\:bg-surface-tint:hover{--tw-bg-opacity: 1;background-color:oklch(var(--surface-tint) / var(--tw-bg-opacity, 1))}.hover\:bg-surface-tint\/60:hover{background-color:oklch(var(--surface-tint) / .6)}.hover\:bg-warn\/15:hover{background-color:oklch(var(--warn) / .15)}.hover\:text-accent:hover{--tw-text-opacity: 1;color:oklch(var(--accent) / var(--tw-text-opacity, 1))}.hover\:text-fg:hover{--tw-text-opacity: 1;color:oklch(var(--fg) / var(--tw-text-opacity, 1))}.hover\:text-fg-muted:hover{--tw-text-opacity: 1;color:oklch(var(--fg-muted) / var(--tw-text-opacity, 1))}.hover\:text-surface:hover{--tw-text-opacity: 1;color:oklch(var(--surface) / var(--tw-text-opacity, 1))}.hover\:underline:hover{text-decoration-line:underline}.focus\:border-accent:focus{--tw-border-opacity: 1;border-color:oklch(var(--accent) / var(--tw-border-opacity, 1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-0:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-1:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-accent\/40:focus{--tw-ring-color: oklch(var(--accent) / .4)}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:text-accent{--tw-text-opacity: 1;color:oklch(var(--accent) / var(--tw-text-opacity, 1))}.group:hover .group-hover\:text-fg{--tw-text-opacity: 1;color:oklch(var(--fg) / var(--tw-text-opacity, 1))}.group:hover .group-hover\:text-fg-muted{--tw-text-opacity: 1;color:oklch(var(--fg-muted) / var(--tw-text-opacity, 1))}@media(prefers-reduced-motion:reduce){.motion-reduce\:transition-none{transition-property:none}}@media(min-width:640px){.sm\:w-44{width:11rem}.sm\:w-64{width:16rem}.sm\:w-\[34rem\]{width:34rem}.sm\:shrink-0{flex-shrink:0}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-\[7rem_6\.5rem_10rem_7rem\]{grid-template-columns:7rem 6.5rem 10rem 7rem}.sm\:flex-row{flex-direction:row}.sm\:items-start{align-items:flex-start}.sm\:items-center{align-items:center}.sm\:border-b-0{border-bottom-width:0px}.sm\:border-r{border-right-width:1px}.sm\:p-6{padding:1.5rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:pb-0{padding-bottom:0}.sm\:pr-6{padding-right:1.5rem}}@media(min-width:768px){.md\:grid-cols-\[minmax\(0\,1fr\)_auto\]{grid-template-columns:minmax(0,1fr) auto}.md\:items-start{align-items:flex-start}.md\:items-end{align-items:flex-end}.md\:justify-end{justify-content:flex-end}}@media(min-width:1024px){.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[minmax\(0\,0\.95fr\)_minmax\(22rem\,1\.05fr\)\]{grid-template-columns:minmax(0,.95fr) minmax(22rem,1.05fr)}.lg\:gap-x-7{-moz-column-gap:1.75rem;column-gap:1.75rem}.lg\:gap-x-8{-moz-column-gap:2rem;column-gap:2rem}.lg\:px-8{padding-left:2rem;padding-right:2rem}}@media(min-width:1280px){.xl\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}} diff --git a/internal/api/dashboardspa/dist/assets/projectOf-CwPPScnJ.js b/internal/api/dashboardspa/dist/assets/projectOf-B27nlS9X.js similarity index 92% rename from internal/api/dashboardspa/dist/assets/projectOf-CwPPScnJ.js rename to internal/api/dashboardspa/dist/assets/projectOf-B27nlS9X.js index ecb65262cd..5cf2b076d9 100644 --- a/internal/api/dashboardspa/dist/assets/projectOf-CwPPScnJ.js +++ b/internal/api/dashboardspa/dist/assets/projectOf-B27nlS9X.js @@ -1 +1 @@ -import{j as c,H as R}from"./index-C20tCZFz.js";function k({value:e,onChange:t,placeholder:n="Search",matchCount:r,totalCount:i,ariaLabel:a="Search list"}){const d=e.length>0&&typeof r=="number"&&typeof i=="number";return c.jsxs("div",{className:"flex items-baseline gap-3 border-b border-rule pb-1",children:[c.jsx("input",{type:"search",value:e,onChange:m=>t(m.target.value),placeholder:n,"aria-label":a,className:"flex-1 bg-transparent border-0 text-body text-fg placeholder:text-fg-faint focus:outline-none focus:ring-0 px-0 py-0.5"}),d&&c.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:[r," / ",i]})]})}const b=/^(.+?)-[a-z0-9]+(?:\.\d+)?$/i;function C(e){return b.exec(e.id)?.[1]??e.id}const o="Orchestration";function u(){return R()??o}const s="(no rig)",l="Maintenance",E=new Set(["mayor","control-dispatcher","oversight-rig.chief-of-staff"]);function g(e){return e.rig&&e.rig.length>0?!1:!!e.template&&E.has(e.template)}const p=/\/control-dispatcher$/;function O(e){return!e.rig||e.rig.length===0?!1:p.test(e.alias??"")}const _=/(?:worker|polecat)(?:-\d+)?$/,f=/(?:\.project-lead|chief-of-staff)$/;function j(e){if(e.state!=="active"&&e.state!=="running"||g(e)||O(e))return!1;const t=e.template??"",n=e.alias??"";if(f.test(t)||f.test(n))return!1;const r=e.session_name;return[t,n,r].filter(a=>a.length>0).map(a=>I(a)).some(a=>_.test(a))}function h(e){return e.toLowerCase().replace(/_/g,"-")}function L(e){if(g(e))return{key:o,label:u()};const t=e.rig??e.pool??e.template;if(!t)return{key:s,label:s};const n=t.split(/[\\/]/).filter(Boolean),r=n[n.length-1]??t;return{key:h(r),label:r}}function w(e){return e.rig&&e.rig.length>0?e.rig:s}const S=new Set(["mayor","control-dispatcher","oversight-rig.chief-of-staff"]),N=new Set(["dog"]);function y(e){return e.rig&&e.rig.length>0?!1:S.has(e.name)}function v(e){return!e.rig||e.rig.length===0?!1:p.test(e.name)}function A(e){if(y(e))return{key:o,label:u()};const t=e.rig&&e.rig.length>0?e.rig:void 0;if(!t&&e.pool&&N.has(e.pool))return{key:l,label:l};const n=t??e.pool;if(!n)return{key:s,label:s};const r=n.split(/[\\/]/).filter(Boolean),i=T(r[r.length-1]??n);return{key:h(i),label:i}}function T(e){return e.endsWith("-main")?e.slice(0,-5):e}const x=/-(?:gc|td|th|[a-z]{4})-[a-z0-9]*[0-9][a-z0-9]*$/;function I(e){const t=e.trim(),n=t.split(/[\\/]/).filter(Boolean),r=n[n.length-1]??t,i=r.replace(x,"");return i.length>0?i:r}function H(e){const{key:t}=A(e);return t===o||t===l||t===s}export{k as L,T as a,H as b,I as c,A as d,v as e,C as f,j as i,w as m,L as s}; +import{j as c,F as R}from"./index-CJ6RRl2D.js";function k({value:e,onChange:t,placeholder:n="Search",matchCount:r,totalCount:i,ariaLabel:a="Search list"}){const d=e.length>0&&typeof r=="number"&&typeof i=="number";return c.jsxs("div",{className:"flex items-baseline gap-3 border-b border-rule pb-1",children:[c.jsx("input",{type:"search",value:e,onChange:m=>t(m.target.value),placeholder:n,"aria-label":a,className:"flex-1 bg-transparent border-0 text-body text-fg placeholder:text-fg-faint focus:outline-none focus:ring-0 px-0 py-0.5"}),d&&c.jsxs("span",{className:"text-label uppercase tracking-wider text-fg-faint tnum",children:[r," / ",i]})]})}const b=/^(.+?)-[a-z0-9]+(?:\.\d+)?$/i;function C(e){return b.exec(e.id)?.[1]??e.id}const o="Orchestration";function u(){return R()??o}const s="(no rig)",l="Maintenance",E=new Set(["mayor","control-dispatcher","oversight-rig.chief-of-staff"]);function g(e){return e.rig&&e.rig.length>0?!1:!!e.template&&E.has(e.template)}const p=/\/control-dispatcher$/;function O(e){return!e.rig||e.rig.length===0?!1:p.test(e.alias??"")}const _=/(?:worker|polecat)(?:-\d+)?$/,f=/(?:\.project-lead|chief-of-staff)$/;function j(e){if(e.state!=="active"&&e.state!=="running"||g(e)||O(e))return!1;const t=e.template??"",n=e.alias??"";if(f.test(t)||f.test(n))return!1;const r=e.session_name;return[t,n,r].filter(a=>a.length>0).map(a=>I(a)).some(a=>_.test(a))}function h(e){return e.toLowerCase().replace(/_/g,"-")}function L(e){if(g(e))return{key:o,label:u()};const t=e.rig??e.pool??e.template;if(!t)return{key:s,label:s};const n=t.split(/[\\/]/).filter(Boolean),r=n[n.length-1]??t;return{key:h(r),label:r}}function w(e){return e.rig&&e.rig.length>0?e.rig:s}const S=new Set(["mayor","control-dispatcher","oversight-rig.chief-of-staff"]),N=new Set(["dog"]);function y(e){return e.rig&&e.rig.length>0?!1:S.has(e.name)}function v(e){return!e.rig||e.rig.length===0?!1:p.test(e.name)}function A(e){if(y(e))return{key:o,label:u()};const t=e.rig&&e.rig.length>0?e.rig:void 0;if(!t&&e.pool&&N.has(e.pool))return{key:l,label:l};const n=t??e.pool;if(!n)return{key:s,label:s};const r=n.split(/[\\/]/).filter(Boolean),i=T(r[r.length-1]??n);return{key:h(i),label:i}}function T(e){return e.endsWith("-main")?e.slice(0,-5):e}const x=/-(?:gc|td|th|[a-z]{4})-[a-z0-9]*[0-9][a-z0-9]*$/;function I(e){const t=e.trim(),n=t.split(/[\\/]/).filter(Boolean),r=n[n.length-1]??t,i=r.replace(x,"");return i.length>0?i:r}function X(e){const{key:t}=A(e);return t===o||t===l||t===s}export{k as L,T as a,X as b,I as c,A as d,v as e,C as f,j as i,w as m,L as s}; diff --git a/internal/api/dashboardspa/dist/assets/useListFilters-C0Eq1DLc.js b/internal/api/dashboardspa/dist/assets/useListFilters-C0Eq1DLc.js deleted file mode 100644 index 6ec63dccad..0000000000 --- a/internal/api/dashboardspa/dist/assets/useListFilters-C0Eq1DLc.js +++ /dev/null @@ -1 +0,0 @@ -import{j as C,r as g,D as Y,E as D,x as tt,p as et}from"./index-C20tCZFz.js";function gt({chips:e,activeIds:n,onToggle:a,legend:p}){return e.length===0?null:C.jsxs("div",{className:"flex items-baseline gap-4 flex-wrap",children:[p&&C.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:p}),e.map(i=>{const d=n.has(i.id);return C.jsx("button",{type:"button",onClick:()=>a(i.id),"aria-pressed":d,className:`text-label uppercase tracking-wider transition-colors duration-150 ease-out-quart focus-mark rounded-sm ${d?"text-fg font-semibold underline decoration-fg underline-offset-4":"text-fg-muted hover:text-fg"}`,children:i.label},i.id)})]})}const st="gcd:listFilters:collapsed:",rt="gcd:listFilters:expanded:",X="gcd:listFilters:sortMode:",m="useListFilters";function B(e,n){return(n?rt:st)+e}function R(e,n){const a=B(e,n),p=Y("localStorage",a,m);if(p.status!=="found")return new Set;try{const i=JSON.parse(p.value);if(Array.isArray(i))return new Set(i.filter(d=>typeof d=="string"))}catch(i){at(a,i)}return new Set}function nt(e,n,a){D("localStorage",B(e,n),JSON.stringify(Array.from(a)),m)}function T(e,n){const a=Y("localStorage",X+e,m);return a.status==="found"&&(a.value==="alpha"||a.value==="activity")?a.value:n}function ot(e,n){D("localStorage",X+e,n,m)}function at(e,n){tt({component:m,operation:"localStorage.parse",message:`${e}: ${et(n)}`})}const ct=[],it=new Set,lt=[];function pt({viewKey:e,rows:n,projectOf:a,searchOf:p,chips:i,initialActiveChipIds:d=lt,defaultCollapsed:f=!1,activityOf:x,defaultSortMode:M="alpha",pinnedProjects:k=ct,nonCollapsibleProjects:I=it}){const $=d.join(","),[N,L]=g.useState(""),[P,_]=g.useState(()=>new Set(d)),[w,A]=g.useState(()=>R(e,f)),[F,v]=g.useState(()=>T(e,M));g.useEffect(()=>{A(R(e,f)),v(T(e,M)),L(""),_(new Set(d))},[e,f,M,$]);const H=g.useCallback(r=>{_(S=>{const l=new Set(S);return l.has(r)?l.delete(r):l.add(r),l})},[]),J=g.useCallback(r=>{A(S=>{const l=new Set(S);return l.has(r)?l.delete(r):l.add(r),nt(e,f,l),l})},[e,f]),U=g.useCallback(r=>w.has(r)?!f:f,[w,f]),q=g.useCallback(r=>{v(r),ot(e,r)},[e]),y=g.useMemo(()=>{const r=N.trim().toLowerCase(),S=i.filter(t=>P.has(t.id)),l=t=>{if(r.length===0)return!0;for(const s of p(t))if(s&&s.toLowerCase().includes(r))return!0;return!1},Z=t=>{if(S.length===0)return!0;for(const s of S)if(s.match(t))return!0;return!1},b=new Map;for(const t of n){if(!l(t)||!Z(t))continue;const s=a(t),o=typeof s=="string"?s:s.key,c=typeof s=="string"?s:s.label,u=b.get(o);u?(u.rows.push(t),u.labelCounts.set(c,(u.labelCounts.get(c)??0)+1)):b.set(o,{rows:[t],labelCounts:new Map([[c,1]])})}const z=t=>{let s="",o=-1,c=!1;for(const[u,h]of t){const j=/[A-Z]/.test(u);(h>o||h===o&&j&&!c)&&(s=u,o=h,c=j)}return s},G=Array.from(b.keys()),O=k.filter(t=>b.has(t)),Q=new Set(O),E=G.filter(t=>!Q.has(t));if(F==="activity"&&x){const t=new Map;for(const s of E){const o=b.get(s);let c=-1/0;if(o)for(const u of o.rows){const h=x(u);typeof h=="number"&&Number.isFinite(h)&&h>c&&(c=h)}t.set(s,c)}E.sort((s,o)=>{const c=t.get(s)??-1/0,u=t.get(o)??-1/0;return c!==u?u-c:s.localeCompare(o)})}else E.sort();const V=[...O,...E],W=t=>I.has(t)?!1:w.has(t)?!f:f;return V.map(t=>{const s=b.get(t),o=s?.rows??[];return{project:s?z(s.labelCounts):t,projectKey:t,rows:o,totalInProject:o.length,collapsed:W(t),collapsible:!I.has(t)}})},[n,N,P,i,a,p,w,f,F,x,k,I]),K=g.useMemo(()=>y.reduce((r,S)=>r+S.totalInProject,0),[y]);return{search:N,setSearch:L,activeChipIds:P,toggleChip:H,isCollapsed:U,toggleProject:J,sortMode:F,setSortMode:q,groups:y,totalMatches:K}}export{gt as F,pt as u}; diff --git a/internal/api/dashboardspa/dist/assets/useListFilters-DQXiYJvO.js b/internal/api/dashboardspa/dist/assets/useListFilters-DQXiYJvO.js new file mode 100644 index 0000000000..474f0526bf --- /dev/null +++ b/internal/api/dashboardspa/dist/assets/useListFilters-DQXiYJvO.js @@ -0,0 +1 @@ +import{j as y,r as g,C as Y,D,x as tt,p as et}from"./index-CJ6RRl2D.js";function gt({chips:e,activeIds:n,onToggle:a,legend:p}){return e.length===0?null:y.jsxs("div",{className:"flex items-baseline gap-4 flex-wrap",children:[p&&y.jsx("span",{className:"text-label uppercase tracking-wider text-fg-muted",children:p}),e.map(i=>{const d=n.has(i.id);return y.jsx("button",{type:"button",onClick:()=>a(i.id),"aria-pressed":d,className:`text-label uppercase tracking-wider transition-colors duration-150 ease-out-quart focus-mark rounded-sm ${d?"text-fg font-semibold underline decoration-fg underline-offset-4":"text-fg-muted hover:text-fg"}`,children:i.label},i.id)})]})}const st="gcd:listFilters:collapsed:",rt="gcd:listFilters:expanded:",X="gcd:listFilters:sortMode:",m="useListFilters";function B(e,n){return(n?rt:st)+e}function R(e,n){const a=B(e,n),p=Y("localStorage",a,m);if(p.status!=="found")return new Set;try{const i=JSON.parse(p.value);if(Array.isArray(i))return new Set(i.filter(d=>typeof d=="string"))}catch(i){at(a,i)}return new Set}function nt(e,n,a){D("localStorage",B(e,n),JSON.stringify(Array.from(a)),m)}function T(e,n){const a=Y("localStorage",X+e,m);return a.status==="found"&&(a.value==="alpha"||a.value==="activity")?a.value:n}function ot(e,n){D("localStorage",X+e,n,m)}function at(e,n){tt({component:m,operation:"localStorage.parse",message:`${e}: ${et(n)}`})}const ct=[],it=new Set,lt=[];function pt({viewKey:e,rows:n,projectOf:a,searchOf:p,chips:i,initialActiveChipIds:d=lt,defaultCollapsed:f=!1,activityOf:E,defaultSortMode:M="alpha",pinnedProjects:k=ct,nonCollapsibleProjects:I=it}){const $=d.join(","),[N,L]=g.useState(""),[P,_]=g.useState(()=>new Set(d)),[w,A]=g.useState(()=>R(e,f)),[C,v]=g.useState(()=>T(e,M));g.useEffect(()=>{A(R(e,f)),v(T(e,M)),L(""),_(new Set(d))},[e,f,M,$]);const H=g.useCallback(r=>{_(S=>{const l=new Set(S);return l.has(r)?l.delete(r):l.add(r),l})},[]),J=g.useCallback(r=>{A(S=>{const l=new Set(S);return l.has(r)?l.delete(r):l.add(r),nt(e,f,l),l})},[e,f]),U=g.useCallback(r=>w.has(r)?!f:f,[w,f]),q=g.useCallback(r=>{v(r),ot(e,r)},[e]),F=g.useMemo(()=>{const r=N.trim().toLowerCase(),S=i.filter(t=>P.has(t.id)),l=t=>{if(r.length===0)return!0;for(const s of p(t))if(s&&s.toLowerCase().includes(r))return!0;return!1},Z=t=>{if(S.length===0)return!0;for(const s of S)if(s.match(t))return!0;return!1},b=new Map;for(const t of n){if(!l(t)||!Z(t))continue;const s=a(t),o=typeof s=="string"?s:s.key,c=typeof s=="string"?s:s.label,u=b.get(o);u?(u.rows.push(t),u.labelCounts.set(c,(u.labelCounts.get(c)??0)+1)):b.set(o,{rows:[t],labelCounts:new Map([[c,1]])})}const z=t=>{let s="",o=-1,c=!1;for(const[u,h]of t){const j=/[A-Z]/.test(u);(h>o||h===o&&j&&!c)&&(s=u,o=h,c=j)}return s},G=Array.from(b.keys()),O=k.filter(t=>b.has(t)),Q=new Set(O),x=G.filter(t=>!Q.has(t));if(C==="activity"&&E){const t=new Map;for(const s of x){const o=b.get(s);let c=-1/0;if(o)for(const u of o.rows){const h=E(u);typeof h=="number"&&Number.isFinite(h)&&h>c&&(c=h)}t.set(s,c)}x.sort((s,o)=>{const c=t.get(s)??-1/0,u=t.get(o)??-1/0;return c!==u?u-c:s.localeCompare(o)})}else x.sort();const V=[...O,...x],W=t=>I.has(t)?!1:w.has(t)?!f:f;return V.map(t=>{const s=b.get(t),o=s?.rows??[];return{project:s?z(s.labelCounts):t,projectKey:t,rows:o,totalInProject:o.length,collapsed:W(t),collapsible:!I.has(t)}})},[n,N,P,i,a,p,w,f,C,E,k,I]),K=g.useMemo(()=>F.reduce((r,S)=>r+S.totalInProject,0),[F]);return{search:N,setSearch:L,activeChipIds:P,toggleChip:H,isCollapsed:U,toggleProject:J,sortMode:C,setSortMode:q,groups:F,totalMatches:K}}export{gt as F,pt as u}; diff --git a/internal/api/dashboardspa/dist/assets/useVisibleRefresh-D_HCcAAw.js b/internal/api/dashboardspa/dist/assets/useVisibleRefresh-DJ0jEjH6.js similarity index 92% rename from internal/api/dashboardspa/dist/assets/useVisibleRefresh-D_HCcAAw.js rename to internal/api/dashboardspa/dist/assets/useVisibleRefresh-DJ0jEjH6.js index 1188e560fa..90fabecf5d 100644 --- a/internal/api/dashboardspa/dist/assets/useVisibleRefresh-D_HCcAAw.js +++ b/internal/api/dashboardspa/dist/assets/useVisibleRefresh-DJ0jEjH6.js @@ -1 +1 @@ -import{r}from"./index-C20tCZFz.js";const F=2e3,w=6e4;function h(e,u,s={}){const a=r.useRef(e);a.current=e;const n=r.useRef(l(s));n.current=l(s);const t=r.useRef(0),c=r.useRef(0),o=r.useRef(!1),{enabled:i,initialBackoffMs:d,maxBackoffMs:B}=n.current;r.useEffect(()=>{if(!i)return;const M=()=>{t.current=0,c.current=0},R=A=>{const f=n.current;f.onError?.(A);const E=Math.min(f.initialBackoffMs*2**t.current,f.maxBackoffMs);t.current+=1,c.current=Date.now()+E},k=()=>{document.hidden||o.current||Date.now(){o.current=!1}))},m=window.setInterval(k,u);return()=>window.clearInterval(m)},[i,u,d,B])}function l(e){return{enabled:e.enabled??!0,initialBackoffMs:e.initialBackoffMs??F,maxBackoffMs:e.maxBackoffMs??w,onError:e.onError??x}}function x(){}export{h as u}; +import{r}from"./index-CJ6RRl2D.js";const F=2e3,w=6e4;function h(e,u,s={}){const a=r.useRef(e);a.current=e;const n=r.useRef(l(s));n.current=l(s);const t=r.useRef(0),c=r.useRef(0),o=r.useRef(!1),{enabled:i,initialBackoffMs:d,maxBackoffMs:B}=n.current;r.useEffect(()=>{if(!i)return;const M=()=>{t.current=0,c.current=0},R=A=>{const f=n.current;f.onError?.(A);const E=Math.min(f.initialBackoffMs*2**t.current,f.maxBackoffMs);t.current+=1,c.current=Date.now()+E},k=()=>{document.hidden||o.current||Date.now(){o.current=!1}))},m=window.setInterval(k,u);return()=>window.clearInterval(m)},[i,u,d,B])}function l(e){return{enabled:e.enabled??!0,initialBackoffMs:e.initialBackoffMs??F,maxBackoffMs:e.maxBackoffMs??w,onError:e.onError??x}}function x(){}export{h as u}; diff --git a/internal/api/dashboardspa/dist/index.html b/internal/api/dashboardspa/dist/index.html index 9a3c8117ba..ecfe97d1b8 100644 --- a/internal/api/dashboardspa/dist/index.html +++ b/internal/api/dashboardspa/dist/index.html @@ -20,8 +20,8 @@ } catch (_) {} })(); - - + +
diff --git a/internal/api/dashboardspa/web/frontend/src/attention/registry.test.ts b/internal/api/dashboardspa/web/frontend/src/attention/registry.test.ts index ae56bca398..ac1d9895e6 100644 --- a/internal/api/dashboardspa/web/frontend/src/attention/registry.test.ts +++ b/internal/api/dashboardspa/web/frontend/src/attention/registry.test.ts @@ -956,6 +956,7 @@ function agent(overrides: Partial): AgentResponse { running: false, state: 'active', suspended: false, + pack_derived: false, ...overrides, }; } diff --git a/internal/api/dashboardspa/web/frontend/src/components/LiveSessionPeek.test.tsx b/internal/api/dashboardspa/web/frontend/src/components/LiveSessionPeek.test.tsx index eeee945908..3bcddbad73 100644 --- a/internal/api/dashboardspa/web/frontend/src/components/LiveSessionPeek.test.tsx +++ b/internal/api/dashboardspa/web/frontend/src/components/LiveSessionPeek.test.tsx @@ -94,6 +94,7 @@ function agent(overrides: Partial & { sessionPresent?: boolean }) state: 'asleep', running: false, suspended: false, + pack_derived: false, ...rest, ...sessionField, }; diff --git a/internal/api/dashboardspa/web/frontend/src/components/agent/AgentDirectives.tsx b/internal/api/dashboardspa/web/frontend/src/components/agent/AgentDirectives.tsx deleted file mode 100644 index b8169d9367..0000000000 --- a/internal/api/dashboardspa/web/frontend/src/components/agent/AgentDirectives.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { Button } from '../Button'; - -export interface AgentDirectivesError { - status?: number; - kind?: string; - message: string; -} - -export function AgentDirectives({ - alias, - prompt, - loading, - error, - onRefresh, -}: { - alias: string; - prompt: string | null; - loading: boolean; - error: AgentDirectivesError | null; - onRefresh: () => void; -}) { - const isNotFound = error?.status === 404 || error?.kind === 'not_found'; - const charsLabel = - prompt !== null - ? `${prompt.length.toLocaleString()} chars` - : loading - ? 'loading' - : error !== null - ? '—' - : '·'; - - return ( -
-
-

Directives

-
- - {charsLabel} - - -
-
- {loading && prompt === null && error === null ? ( -

Loading directives.

- ) : isNotFound ? ( -

- Agent {alias} has no entry in city config. -

- ) : error !== null ? ( -

- {error.status ? `${error.status} ` : ''} - {error.message} -

- ) : prompt !== null ? ( -
-          {prompt}
-        
- ) : null} -
- ); -} diff --git a/internal/api/dashboardspa/web/frontend/src/components/beads/BeadDependencies.test.tsx b/internal/api/dashboardspa/web/frontend/src/components/beads/BeadDependencies.test.tsx index 34391a5330..50644bb3bb 100644 --- a/internal/api/dashboardspa/web/frontend/src/components/beads/BeadDependencies.test.tsx +++ b/internal/api/dashboardspa/web/frontend/src/components/beads/BeadDependencies.test.tsx @@ -23,7 +23,10 @@ function bead(id: string, status: BeadStatus, extra: Partial = {} } function nodeFor(id: string, beads: DashboardBead[]) { - const graph = buildBeadGraph(beads); + // buildBeadGraph consumes SupervisorBead (priority: number), while these + // fixtures model DashboardBead (priority: number | null). Normalize the + // non-priority rows to 0 so the graph input matches the supervisor shape. + const graph = buildBeadGraph(beads.map((b) => ({ ...b, priority: b.priority ?? 0 }))); const node = graph.nodes.get(id); if (!node) throw new Error(`no node for ${id}`); return node; diff --git a/internal/api/dashboardspa/web/frontend/src/routes/AgentDetail.test.tsx b/internal/api/dashboardspa/web/frontend/src/routes/AgentDetail.test.tsx index 8c99e0fec9..57943b2a2a 100644 --- a/internal/api/dashboardspa/web/frontend/src/routes/AgentDetail.test.tsx +++ b/internal/api/dashboardspa/web/frontend/src/routes/AgentDetail.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import { NowProvider } from '../contexts/NowContext'; @@ -15,14 +15,6 @@ vi.mock('../api/client', () => ({ this.kind = kind; } }, - apiErrorParts: (err: unknown, fallback = 'request failed') => { - if (err instanceof Error && 'status' in err) { - const apiErr = err as Error & { status: number; kind?: string }; - return { message: apiErr.message, status: apiErr.status, kind: apiErr.kind }; - } - if (err instanceof Error) return { message: err.message }; - return { message: fallback }; - }, formatApiError: (err: unknown, fallback = 'request failed') => { if (err instanceof Error && 'status' in err) { const apiErr = err as Error & { status: number }; @@ -36,7 +28,6 @@ vi.mock('../api/client', () => ({ const mockListSupervisorSessions = vi.hoisted(() => vi.fn()); const mockListSupervisorBeads = vi.hoisted(() => vi.fn()); const mockListSupervisorMail = vi.hoisted(() => vi.fn()); -const mockFetchSupervisorAgentPrime = vi.hoisted(() => vi.fn()); const mockUseVisibleRefresh = vi.hoisted(() => vi.fn()); vi.mock('../supervisor/sessionReads', () => ({ @@ -57,10 +48,6 @@ vi.mock('../supervisor/mailReads', () => ({ listSupervisorMail: mockListSupervisorMail, })); -vi.mock('../supervisor/agentReads', () => ({ - fetchSupervisorAgentPrime: mockFetchSupervisorAgentPrime, -})); - vi.mock('../contexts/ViewingAsContext', () => ({ useViewingAs: () => ({ viewingAs: { alias: 'stephanie', isOperator: true }, @@ -99,7 +86,6 @@ describe('AgentDetailPage error reporting', () => { mockListSupervisorSessions.mockResolvedValue({ items: [] }); mockListSupervisorBeads.mockRejectedValue(new Error('beads unavailable')); mockListSupervisorMail.mockResolvedValue({ items: [] }); - mockFetchSupervisorAgentPrime.mockResolvedValue({ agent: 'mayor', prompt: '', bytes: 0 }); mockReportClientError.mockReset(); mockUseVisibleRefresh.mockClear(); }); @@ -154,50 +140,6 @@ describe('AgentDetailPage error reporting', () => { expect(screen.queryByText('Loading beads.')).toBeNull(); }); - it('fetches directives through the supervisor prime API when refreshed', async () => { - mockListSupervisorSessions.mockResolvedValue({ - items: [ - { - id: 'gc-session-1', - session_name: 'mayor', - alias: 'mayor', - template: 'mayor', - title: 'mayor', - state: 'active', - provider: 'claude', - running: true, - attached: false, - created_at: '2026-06-01T00:00:00Z', - }, - ], - }); - mockListSupervisorBeads.mockResolvedValue({ items: [] }); - mockFetchSupervisorAgentPrime.mockResolvedValue({ - agent: 'mayor', - prompt: 'DIRECTIVE BODY', - bytes: 'DIRECTIVE BODY'.length, - }); - - render( - - - - } /> - - - , - ); - - fireEvent.click(await screen.findByRole('button', { name: 'Refresh' })); - await waitFor(() => { - expect(mockFetchSupervisorAgentPrime).toHaveBeenCalledWith('mayor'); - }); - expect(await screen.findByText('DIRECTIVE BODY')).toBeTruthy(); - }); - it('uses supervisor SSE rather than visible polling for session and bead refreshes', async () => { mockListSupervisorSessions.mockResolvedValue({ items: [ diff --git a/internal/api/dashboardspa/web/frontend/src/routes/AgentDetail.tsx b/internal/api/dashboardspa/web/frontend/src/routes/AgentDetail.tsx index bbbc885921..afab9307bd 100644 --- a/internal/api/dashboardspa/web/frontend/src/routes/AgentDetail.tsx +++ b/internal/api/dashboardspa/web/frontend/src/routes/AgentDetail.tsx @@ -1,7 +1,7 @@ import { errorMessage, GC_EVENT_PREFIX } from 'gas-city-dashboard-shared'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; -import { apiErrorParts, formatApiError } from '../api/client'; +import { formatApiError } from '../api/client'; import { BeadDetailModal } from '../components/BeadDetailModal'; import { Button } from '../components/Button'; import { PageHeader } from '../components/PageHeader'; @@ -9,7 +9,6 @@ import { RelatedEntities } from '../components/RelatedEntities'; import { StatusBadge, stateTone } from '../components/StatusBadge'; import { AgentBeadsAssigned } from '../components/agent/AgentBeadsAssigned'; import { AgentChatThread } from '../components/agent/AgentChatThread'; -import { AgentDirectives, type AgentDirectivesError } from '../components/agent/AgentDirectives'; import { AgentLivePeek } from '../components/agent/AgentLivePeek'; import { AgentMetadata } from '../components/agent/AgentMetadata'; import { useOperatorConfig } from '../contexts/OperatorConfigContext'; @@ -19,7 +18,6 @@ import { useAbortableVisibleRefresh } from '../hooks/useAbortableVisibleRefresh' import { useEntityLinks } from '../hooks/useEntityLinks'; import { useGcEventRefresh } from '../hooks/useGcEvents'; import { reportClientError } from '../lib/clientErrorReporting'; -import { fetchSupervisorAgentPrime } from '../supervisor/agentReads'; import { listSupervisorBeadsAssignedTo, type SupervisorBead } from '../supervisor/beadReads'; import { listSupervisorMail, type SupervisorMailItem } from '../supervisor/mailReads'; import { listSupervisorSessions, type SupervisorSession } from '../supervisor/sessionReads'; @@ -53,10 +51,6 @@ export function AgentDetailPage() { const now = useNow(); - const [directivesPrompt, setDirectivesPrompt] = useState(null); - const [directivesLoading, setDirectivesLoading] = useState(false); - const [directivesError, setDirectivesError] = useState(null); - const decoded = useMemo(() => { try { return decodeURIComponent(slug); @@ -196,38 +190,6 @@ export function AgentDetailPage() { ? chatState.error : null; - // Directives: lazy-fetch the agent's composed prompt from the supervisor. - // Cached for the lifetime of the page (no auto-refresh); operator can - // manually re-pull. Bail out (render nothing) when there's no alias - // candidate — supervisor prime is alias-keyed, not id-keyed. - const primeAlias = useMemo(() => { - if (session === null) return null; - return session.alias ?? session.template ?? null; - }, [session]); - - const refreshDirectives = useCallback(async () => { - if (primeAlias === null) return; - setDirectivesLoading(true); - setDirectivesError(null); - try { - const result = await fetchSupervisorAgentPrime(primeAlias); - setDirectivesPrompt(result.prompt); - } catch (err) { - const parts = apiErrorParts(err, 'directives fetch failed'); - const directivesError: { - status?: number; - kind?: string; - message: string; - } = { message: parts.message }; - if (parts.status !== undefined) directivesError.status = parts.status; - if (parts.kind !== undefined) directivesError.kind = parts.kind; - setDirectivesError(directivesError); - setDirectivesPrompt(null); - } finally { - setDirectivesLoading(false); - } - }, [primeAlias]); - // Related entities (gascity-dashboard-j4x). Focus on the session id so // the index surfaces the beads, formula runs, and PRs adjacent to this // agent's work. Hook is called unconditionally (before the early @@ -365,16 +327,6 @@ export function AgentDetailPage() { - {primeAlias !== null && ( - void refreshDirectives()} - /> - )} - = {}): AgentRe available: true, running: state === 'active' || state === 'running', suspended: false, + pack_derived: false, state, ...overrides, }; diff --git a/internal/api/dashboardspa/web/frontend/src/routes/Beads.render.test.tsx b/internal/api/dashboardspa/web/frontend/src/routes/Beads.render.test.tsx index adb0c1d092..aaf5ede628 100644 --- a/internal/api/dashboardspa/web/frontend/src/routes/Beads.render.test.tsx +++ b/internal/api/dashboardspa/web/frontend/src/routes/Beads.render.test.tsx @@ -95,25 +95,23 @@ describe('BeadsPage supervisor reads', () => { expect(screen.getByText('Read-only')).toBeTruthy(); }); - it('disables the per-bead close/nudge actions in read-only mode', async () => { + it('disables the per-bead close action in read-only mode', async () => { renderPage('/beads?bead=td-bead-abc123', [], { readOnly: true }); const dialog = await screen.findByRole('dialog'); // Scope to the bead-action group: the modal's own dismiss control also - // carries aria-label "Close", so query the actions row via Nudge (unique to - // the action row) and assert the writes there are disabled. There is no - // operator Claim action (gascity-dashboard-2j8e.8) — the human is never a - // bead assignee. - const actions = (within(dialog).getByRole('button', { name: 'Nudge' }) as HTMLButtonElement) - .parentElement; + // carries aria-label "Close", so locate the action-row Close (its visible + // text is exactly "Close", unlike the "×" dismiss) and assert the write + // there is disabled. There is no operator Claim action + // (gascity-dashboard-2j8e.8) — the human is never a bead assignee. + const closeButton = within(dialog) + .getAllByRole('button', { name: /^close$/i }) + .find((button) => button.textContent?.trim() === 'Close') as HTMLButtonElement; + expect(closeButton).toBeTruthy(); + const actions = closeButton.parentElement; expect(actions).not.toBeNull(); expect(within(actions as HTMLElement).queryByRole('button', { name: 'Claim' })).toBeNull(); - for (const name of ['Close', 'Nudge']) { - const button = within(actions as HTMLElement).getByRole('button', { - name, - }) as HTMLButtonElement; - expect(button.disabled).toBe(true); - } + expect(closeButton.disabled).toBe(true); // The action row carries the shared glyph+word affordance, not a bare // dimmed button (DESIGN.md §States have words). expect(within(actions as HTMLElement).getByText('Read-only')).toBeTruthy(); diff --git a/internal/api/dashboardspa/web/frontend/src/routes/Beads.test.tsx b/internal/api/dashboardspa/web/frontend/src/routes/Beads.test.tsx index 6bb3199558..6f73b71538 100644 --- a/internal/api/dashboardspa/web/frontend/src/routes/Beads.test.tsx +++ b/internal/api/dashboardspa/web/frontend/src/routes/Beads.test.tsx @@ -75,16 +75,8 @@ beforeEach(() => { return jsonResponse({ status: 'ok' }); } if (url.pathname === '/v0/city/test-city/bead/gascity-0001/close' && method === 'POST') { - supervisorWrites.push({ - method, - path: url.pathname, - body: await requestJson(input, init), - }); - return jsonResponse({ status: 'closed' }); - } - if (url.pathname === '/v0/city/test-city/agent/mayor/nudge' && method === 'POST') { supervisorWrites.push({ method, path: url.pathname }); - return jsonResponse({ status: 'ok' }); + return jsonResponse({ status: 'closed' }); } if (url.pathname === '/v0/city/test-city/sessions') { return jsonResponse({ items: [], total: 0 }); @@ -267,7 +259,7 @@ describe('BeadsPage', () => { ]); }); - it('closes and nudges beads directly through the supervisor API', async () => { + it('closes beads directly through the supervisor API', async () => { renderPage('/beads?bead=gascity-0001'); const detailDialog = await screen.findByRole('dialog'); @@ -275,9 +267,6 @@ describe('BeadsPage', () => { // (gascity-dashboard-2j8e.8). expect(within(detailDialog).queryByRole('button', { name: /^claim$/i })).toBeNull(); - fireEvent.click(within(detailDialog).getByRole('button', { name: /^nudge$/i })); - await screen.findByText(/nudged mayor/i); - const closeButton = within(detailDialog) .getAllByRole('button', { name: /^close$/i }) .find((button) => button.textContent?.trim() === 'Close'); @@ -287,24 +276,14 @@ describe('BeadsPage', () => { const closeDialog = await screen.findByRole('heading', { name: /close gascity-0001/i }); const modal = closeDialog.closest('[role="dialog"]'); expect(modal).toBeTruthy(); - fireEvent.change(within(modal as HTMLElement).getByLabelText(/reason/i), { - target: { value: ' verified done ' }, - }); fireEvent.click(within(modal as HTMLElement).getByRole('button', { name: /close bead/i })); await screen.findByText(/closed gascity-0001/i); await waitFor(() => { expect(supervisorWrites).toEqual([ - { - method: 'POST', - path: '/v0/city/test-city/agent/mayor/nudge', - }, { method: 'POST', path: '/v0/city/test-city/bead/gascity-0001/close', - body: { - reason: 'verified done', - }, }, ]); }); diff --git a/internal/api/dashboardspa/web/frontend/src/routes/Beads.tsx b/internal/api/dashboardspa/web/frontend/src/routes/Beads.tsx index 62581db6a9..7ce3c150f7 100644 --- a/internal/api/dashboardspa/web/frontend/src/routes/Beads.tsx +++ b/internal/api/dashboardspa/web/frontend/src/routes/Beads.tsx @@ -24,11 +24,7 @@ import { beadProject } from '../hooks/projectOf'; import { listSupervisorAgents, type SupervisorAgent } from '../supervisor/agentReads'; import { listSupervisorBeads, type SupervisorBead } from '../supervisor/beadReads'; import { listSupervisorRigs } from '../supervisor/rigReads'; -import { - closeSupervisorBead, - createAndSlingSupervisorBead, - nudgeSupervisorAgent, -} from '../supervisor/beadWrites'; +import { closeSupervisorBead, createAndSlingSupervisorBead } from '../supervisor/beadWrites'; import { listSupervisorSessions } from '../supervisor/sessionReads'; const EMPTY_IDS: ReadonlySet = new Set(); @@ -43,8 +39,6 @@ const CLOSED_CHIP_ID = 'closed'; // at most one refetch per window and a latency spike can no longer empty it. const BOARD_REFRESH_COALESCE_MS = 10_000; -type BeadAction = 'close' | 'nudge'; - interface ActionMessage { tone: 'ok' | 'error'; text: string; @@ -88,11 +82,7 @@ export function BeadsPage() { const [showClosed, setShowClosed] = useState(false); const [selectedId, setSelectedId] = useState(selectedBeadParam); const [closing, setClosing] = useState(null); - const [closeReason, setCloseReason] = useState(''); - const [actionInFlight, setActionInFlight] = useState<{ - id: string; - action: BeadAction; - } | null>(null); + const [actionInFlight, setActionInFlight] = useState(null); const [actionMessage, setActionMessage] = useState(null); const [creating, setCreating] = useState(false); const [createInFlight, setCreateInFlight] = useState(false); @@ -194,30 +184,20 @@ export function BeadsPage() { if (selectedBeadParam !== null) setSelectedId(selectedBeadParam); }, [selectedBeadParam]); - const runAction = useCallback( - async (bead: SupervisorBead, action: BeadAction, reason?: string) => { + const runClose = useCallback( + async (bead: SupervisorBead) => { // Defense-in-depth: the disabled buttons already block this, but a // keyboard/programmatic path must never reach a write the server 405s. if (readOnly) return; - setActionInFlight({ id: bead.id, action }); + setActionInFlight(bead.id); setActionMessage(null); try { - if (action === 'close') { - await closeSupervisorBead(bead.id, reason); - setClosing(null); - setCloseReason(''); - setActionMessage({ tone: 'ok', text: `Closed ${bead.id}.` }); - } else { - const assignee = bead.assignee?.trim() ?? ''; - if (assignee.length === 0) { - throw new Error('Assigned agent is required before nudging.'); - } - await nudgeSupervisorAgent(assignee); - setActionMessage({ tone: 'ok', text: `Nudged ${assignee}.` }); - } + await closeSupervisorBead(bead.id); + setClosing(null); + setActionMessage({ tone: 'ok', text: `Closed ${bead.id}.` }); await refresh(); } catch (err) { - setActionMessage({ tone: 'error', text: formatApiError(err, `${action} failed`) }); + setActionMessage({ tone: 'error', text: formatApiError(err, 'close failed') }); } finally { setActionInFlight(null); } @@ -310,10 +290,8 @@ export function BeadsPage() { // operator), so there is no inline Claim affordance. const renderBeadActions = useCallback( (bead: SupervisorBead) => { - const assignee = bead.assignee?.trim() ?? ''; const busy = actionInFlight !== null; - const actionLabel = - actionInFlight?.id === bead.id ? actionInFlight.action.replace('_', ' ') : null; + const actionLabel = actionInFlight === bead.id ? 'closing' : null; const roTitle = readOnly ? READ_ONLY_CONTROL_TITLE : undefined; @@ -330,27 +308,16 @@ export function BeadsPage() { title={roTitle} disabled={readOnly || busy || bead.status === 'closed'} onClick={() => { - setCloseReason(''); setActionMessage(null); setClosing(bead); }} > Close - ); }, - [actionInFlight, readOnly, runAction], + [actionInFlight, readOnly], ); const synopsis = useMemo( @@ -519,10 +486,7 @@ export function BeadsPage() { { - if (actionInFlight === null) { - setClosing(null); - setCloseReason(''); - } + if (actionInFlight === null) setClosing(null); }} title={closing ? `Close ${closing.id}` : 'Close bead'} caption={closing?.title} @@ -534,10 +498,7 @@ export function BeadsPage() { size="sm" tone="quiet" disabled={actionInFlight !== null} - onClick={() => { - setClosing(null); - setCloseReason(''); - }} + onClick={() => setClosing(null)} > Cancel @@ -548,7 +509,7 @@ export function BeadsPage() { title={readOnly ? READ_ONLY_CONTROL_TITLE : undefined} disabled={readOnly || closing === null || actionInFlight !== null} onClick={() => { - if (closing) void runAction(closing, 'close', closeReason); + if (closing) void runClose(closing); }} > Close bead @@ -556,16 +517,9 @@ export function BeadsPage() { } > -